navree
Documentation

Search

Full-text and vector search over your entities with #[derive(Searchable)], on PostgreSQL, Meilisearch, Typesense or an embedded Tantivy index, kept in sync through the queue.

On this page

Introduction

Featuresearch A CRM lives or dies by its search box. Typing "ada lov" should find Ada Lovelace the contact, Lovelace Analytics the company and the "Lovelace renewal" deal, ranked by relevance, in a few milliseconds, and only the records the person is allowed to see.

navree-search adds full-text and vector search to the entities you already have. You derive Searchable on a SeaORM model, register it with SearchModule, and from then on:

  • every insert, update and delete made through Repo queues a sync job in the same commit, so the index follows the database without code in your services;
  • the Search service queries the index and returns your models, in relevance order, as a normal Page;
  • navree search:import Deal builds or rebuilds an index from the table.

One API runs on five engines. Pick the one that fits your deployment and change it later without touching your queries:

EngineWhat it isGood for
postgresFull-text search in your database: PostgreSQL tsvector with a GIN index and optional trigram typo tolerance, or SQLite FTS5 when the app runs on SQLiteApps that want no extra service
meilisearchA Meilisearch serverTypo tolerance and instant search out of the box
typesenseA Typesense server or clusterHigh query volume, clustering
tantivyAn embedded Tantivy index on local disk, inside your binarySingle-server apps and offline tools
memoryAn in-process indexTests

Vector search stores embeddings in pgvector or Qdrant next to the full-text index, so one query can mix keyword and semantic relevance.

Installation

Search is not part of the default full feature set. Turn on the search feature of the navree facade:

Cargo.toml

The search feature includes the PostgreSQL, Meilisearch, Typesense and memory engines; the server engines talk HTTP through navree-http-client. The embedded engine compiles Tantivy into your binary, so it has a feature of its own:

Cargo.toml

The postgres engine on PostgreSQL and the pgvector store need navree-db's PostgreSQL driver (the postgres feature). On SQLite the postgres engine works with the default driver.

Configuration

Add SearchModule to your app root, after the database and the queue:

src/app.rs

The module is global: feature modules inject Arc<Search> without importing it. from_config(), and a plain .module(SearchModule), read the search section:

config/app.toml

A queue other than the default one needs a worker: add it to queue.queues (queues = ["default", "search"]), or run navree queue:work --queue search next to your other workers.

Keep the engine's API key out of the TOML file. In .env:

.env

Every key can come from the environment: prefix it with APP_ and use __ between levels, as in APP_SEARCH__ENGINE=typesense or APP_SEARCH__TANTIVY__PATH=/var/lib/crm/search. An invalid engine name, a missing url for a server engine or a missing key for Typesense fails the boot with a message that names the key.

To configure the module in code, pass an engine, or full SearchOptions, to for_root:

Rust

At boot the module pushes every registered index's settings to the engine. An engine that is down at boot doesn't stop the app: the failure is logged and the settings are pushed on first use.

Choosing an engine

TOML

The module contributes a migration set named search that creates one table, search_documents, holding each document's fields (jsonb) and a weighted tsvector with a GIN index. Field boosts map to the four tsvector weights (5 and up A, 3 and 4 B, 2 C, 1 D). Your own tables are not changed. With trigram = true the engine enables the pg_trgm extension and adds a trigram index on the text the first time it runs. Each word of the query then matches either the tsvector or, by trigram word similarity, the text, so a misspelled word ("ada lovlace") still finds its record while the other words keep narrowing the results. Queries run on the app's connection pool, or inside the request's transaction when it already wrote.

On SQLite, in development and tests, the same engine uses an FTS5 table, search_documents_fts, that the migration creates next to search_documents: one column per boost, ranked with BM25. Prefixes match there; misspellings don't.

The migration set is part of the module whatever the engine; with another engine its table stays empty.

Making an entity searchable

Derive Searchable on a SeaORM model and mark the fields that go into the index. This is the CRM's contact, with search added:

crates/crm-core/src/entities/contact.rs

The derive implements Searchable for the module's Entity, so you search with the same type your access rules use (Contact). The primary key is always stored; fields without #[searchable] stay out of the index, and the entity needs a single-column primary key.

Full-text and embedded fields go into the document as serde serializes them. Filter and sort fields hold the column's database value, the form access rules compare: an enum's stored string ("closed_won", whatever its serde name), a UUID hyphenated, a timestamp in UTC. Filter with those values: filter("stage", "closed_won"). A field's column is the field's name, or its #[sea_orm(column_name = "..")].

Field attributeEffect
#[searchable]Full-text field, matched by the query text
#[searchable(boost = 3)]Full-text field that weighs 3 times as much as a plain one (1 to 10)
#[searchable(filter)]Exact-match field for filter, filter_in, range and filter_null; not matched by the text
#[searchable(sort)]Field you can sort on
#[searchable(filter, sort)]Both
#[searchable(embed)]Text that goes into the record's embedding
#[searchable(boost = 2, embed)]Flags combine in one attribute
#[searchable(rename = "email_address")]The field's name in the index (access rules still name the column)
Model attributeDefaultEffect
namethe table nameThe name commands and results use: navree search:import Contact, hit.kind == "Contact"
indexthe table nameThe index name, after search.prefix (crm_contacts)
soft_deletenoneA nullable timestamp column: rows where it is set are removed from the index
workspacethe entity's WorkspaceScoped columnThe column that scopes documents to a workspace

Then register the entity with SearchModule::for_feature, in the module that owns it:

crates/crm-core/src/module.rs

Registration does three things: it adds the index to search:import and search:status, pushes the index settings to the engine at boot, and subscribes the entity's changes to the sync observer. Two entities can't share an index name or a name; the boot fails if they do.

The derive builds a document from the row alone. When a document needs data from other tables, implement Searchable by hand. A deal is easier to find by its company's name:

crates/crm-deals/src/search.rs

Return Ok(None) to keep a record out of the index (an archived deal, a draft). A record that returns None after it was indexed is removed. Only the fields settings() declares reach the index. A hand-written settings() names its soft-delete and workspace columns itself: .soft_delete("deleted_at"), .workspace("workspace_id"). Give a filter field the column's database value, as deal.stage.as_str() does here, so that access rules on that column filter in the engine exactly.

When a company is renamed, its deals' documents are stale. Queue them for a re-sync from an event listener, or wherever you rename the company:

Rust

Keeping indexes in sync

SearchModule registers a change observer on the unit of work. When a unit of work that wrote a searchable entity through Repo commits, the observer queues one search.sync job per entity type (and per workspace) with the changed ids. The job is part of the commit: with the database queue driver its row is written in the same transaction, and the memory driver queues it right after the commit. So:

  • a request that fails (status 400 or above) rolls back its writes and queues nothing;
  • the worker runs after the commit, so it always indexes committed data;
  • a burst of writes to the same records collapses into one sync, since sync jobs are unique per entity and ids for search.unique_for_secs (5 seconds).

The job loads the current rows and upserts or deletes their documents. It retries with backoff like any job, so an engine that is down for a minute catches up on its own.

search.syncBehavior
queued (default)Sync jobs on the search.queue queue, run by queue:work
inlineThe documents are written after the commit by a background task, one sync at a time in commit order. The response does not wait for it.
offNothing is synced. Use search:import or reindex yourself.

With queued and no QueueModule in the app, sync jobs go to the app's dyn Dispatcher if it has one; with neither, the module syncs inline and logs a warning once.

Writes that bypass Repo (raw SQL, update_many, a migration that backfills a column) are not observed. Re-sync those records with search.reindex::<E>(ids), or rebuild the index with search:import. reindex queues a sync job with sync = "queued" and syncs the records right away otherwise (off included).

To pause syncing for a block of work, such as a bulk import that ends with a full reindex, wrap it. Changes committed while the future runs are not synced, so commit inside it:

Rust

Importing and rebuilding indexes

search:import indexes every row of an entity, in batches of search.chunk_size:

Shell
Text
CommandWhat it does
search:import <Name>Upserts every row's document. Existing documents are replaced; rows the document filter skips are removed.
search:import <Name> --freshBuilds a new index, imports into it and swaps it in. Searches keep working on the old index until the swap, and records synced while the import runs are synced again after it, so no change is lost.
search:import --allEvery registered index (with --fresh too).
search:flush <Name>Removes every document from the index.
search:settings [<Name>]Pushes index settings (fields, synonyms, stop words) to the engine; every index without a name.
search:statusEach index with its engine, document count, row count and the jobs pending on search.queue.

Run search:import --all --fresh after you change a model's searchable fields, and as part of a deploy that changes them. search.import::<E>() and search.import_fresh::<E>() do the same from code, for an admin screen or a scheduled task; both return an ImportReport with the rows read, documents written and rows skipped.

Searching

Inject Arc<Search> and start a query with the entity type and the text. paginate takes a PageQuery and returns a Page of models, in relevance order:

crates/crm-companies/src/contacts/controller.rs
JSON

The engine returns ids and scores. Navree loads the rows from the database in one query and keeps the engine's order, so you always return current data, and your resource types work unchanged. Rows deleted since the last sync are dropped from the page.

Filters and sorting

Filters apply to #[searchable(filter)] fields; sorts to #[searchable(sort)] fields. Anything else fails with SearchError::NotFilterable or NotSortable before a request reaches the engine.

Rust
MethodMatches
filter(field, value)Equal to value
filter_not(field, value)Not equal to value (records without a value included)
filter_in(field, values)Equal to one of values
range(field, range)Inside a Rust range: 10..20, 10.., ..=20 (numbers and dates)
filter_null(field)Without a value
filter_by(filter)Any combination of Filter::eq, ne, is_in, range and null under Filter::All, Any and Not
sort_asc(field), sort_desc(field)Sort by the field, records without a value last; relevance breaks ties. Without a sort, results are by relevance.

Values compare by JSON type: a number never equals a string. Dates and timestamps are stored in the index as Unix milliseconds, so ranges and sorts on them work on every engine; pass dates (NaiveDate, DateTime<Utc>) or their ISO strings.

An empty query text matches every document, so a filtered empty query is a fast list: search.query::<Deal>("").filter("owner_id", me).sort_desc("expected_close_date"). ids(limit) returns the first matches' primary keys and count() the number of matches.

Access control

accessible(scope) takes the Accessible<E, Read> extractor from authorization. Navree turns the user's ability conditions into engine filters when they only use filterable fields on boolean, number, string, UUID or enum columns (the usual owner_id = me rule becomes filter("owner_id", me)), with the same rule order and the same comparisons as SQL lists, so pages stay full and totals stay exact. Other conditions are applied when the rows are loaded, with the same SQL filter as Accessible lists: conditions on fields the index doesn't hold, and on date and time columns, which the index keeps in milliseconds while the database compares finer. Records the user can't read are then dropped from the page, and its total can still count them. Mark the columns your rules use as filter to keep totals exact.

A rule the engine can't evaluate exactly takes the same path to the rows: on Meilisearch, a range on a text column (it compares numbers only); on Typesense, !=, not_in and null tests (it doesn't index missing values) and ranges on text; with Qdrant behind similar or hybrid, equality on a float. The PostgreSQL, Tantivy and memory engines run every translated rule themselves.

Hits and highlights

hits returns each record with its score and the matched text, marked up for the search box:

Rust

Hit<T> has record, score (0 to 1, higher is better) and highlights, a map of field to its text, HTML-escaped, with <mark> around the matches; fields without a match are left out. Change the tags with .highlight_tags("<em>", "</em>"). Hit<T> serializes as the record's fields plus _score and _highlights, and hit.map(f) converts the record.

Searching several entities at once

The command palette searches contacts, companies and deals in one call. across runs one query per index in parallel and merges the results by score:

Rust

Each include takes that entity's Accessible scope. A result carries the name of its index as kind, the first full-text field as title and its highlights; call hit.load::<Contact>(&uow) for the whole record (None for a hit of another kind).

Workspaces

In an app with workspaces and row isolation, a searchable entity that implements WorkspaceScoped stores its workspace column (workspace_id by default) in every document. The derive finds the column; a hand-written settings() names it with .workspace("workspace_id").

Every query on such an index names the workspace, as WorkspaceRepo does for rows. In a request, pass the CurrentWorkspace id; in jobs and commands, the workspace you work in:

Rust

A query without a workspace fails with SearchError::WorkspaceRequired, so one workspace's search never returns another's records. across and similar take .workspace(id) the same way. Sync jobs carry their records' workspace, and on PostgreSQL they read the rows inside it, so row-level security policies hold; search:import imports workspace by workspace there.

All workspaces share one index per entity, filtered by workspace. Schema and database isolation are not supported by search: the sync reads the app's own tables.

Full-text search finds the words people type. Vector search finds records that mean the same thing: "customers unhappy with onboarding" finds the contact whose notes say "struggled to get the team set up". Mark the fields that describe a record with #[searchable(embed)]:

Rust

On each sync, the embedded fields are joined into one text, one field per line, and turned into a vector by the app's embeddings provider: an Arc<dyn Embeddings> the app registers, usually a wrapper around an embedding model's API:

Rust

Vectors are stored in the configured vector store:

config/app.toml

With vectors on, the boot fails without an Embeddings provider. With pgvector, the store enables the vector extension and creates a search_vectors table, with an embedding vector(1536) column and an HNSW index, the first time it is used. With Qdrant, each index gets a collection of the same name. The memory store is for tests.

Query by meaning with similar, or blend both kinds of relevance with hybrid:

Rust

run() returns Hits, nearest first, with the similarity (0 to 1) as score. Filters, workspaces and accessible work the same for vector queries. hybrid scores the best candidates of each side (at least 100, or as many as the page needs) as (1 - weight) * keyword + weight * vector. Embeddings are cached in memory by text, so re-syncing an unchanged record costs no call to the provider.

Testing

Use the memory engine with inline syncing. Writes through Repo are then searchable as soon as the unit of work commits, and nothing leaves the process:

apps/crm-api/tests/contact_search.rs
  • flush_pending() waits until the inline syncs queued so far have finished. With sync = "queued", run the queue instead: crm.run_queue().await, or app.get::<Queue>().await.run_pending().await in a plain TestApp.
  • The memory engine supports every query method, typo tolerance included, and ranks with the same field boosts, so tests and production agree on what matches. Exact scores differ between engines; assert on order and membership, not on _score.
  • To check that a write queues a sync without running it, install a QueueFake and assert on the sync job, navree::search::SyncDocuments (job name search.sync): fake.assert_dispatched::<SyncDocuments>(|job| job.index == "contacts").
  • search.assert_indexed::<Contact>(id) and search.assert_not_indexed::<Contact>(id) check the index directly.
  • For vector search, set search.vectors.store = "memory" and register a fake Embeddings with override_value(Arc::new(fake) as Arc<dyn Embeddings>).
  • To test a hand-written document(), build a SearchContext::new(tx, app.ctx().clone(), None) around a transaction.

Every engine implements the SearchEngine trait, which you can implement for an engine of your own and plug in with Engine::custom(Arc::new(engine)). Override its filters_exactly(filter) to return false for filter shapes your engine doesn't evaluate as the SQL filter does; access rules of those shapes are then applied to the loaded rows.

Reference

Configuration

KeyDefaultMeaning
search.enginepostgrespostgres, meilisearch, typesense, tantivy or memory
search.urlnoneThe server URL (Meilisearch, Typesense)
search.keynoneThe API key; required for Typesense
search.nodes[]Further Typesense nodes; a comma-separated string from the environment
search.prefix""Put in front of every index name
search.queuedefaultThe queue of search.sync jobs
search.syncqueuedqueued, inline or off
search.unique_for_secs5Window in which sync jobs for the same records collapse
search.chunk_size500Rows per batch for imports
search.timeout_secs5Time allowed for one engine request
search.postgres.languageenglishThe text search configuration
search.postgres.trigramfalseTypo tolerance with pg_trgm
search.tantivy.pathstorage/searchIndex directory
search.tantivy.memory_budget_mb64Writer buffer in MB (less than 15 counts as 15)
search.vectors.storenonepgvector, qdrant or memory; vector search is off without it
search.vectors.url, search.vectors.keynoneThe Qdrant server
search.vectors.dimensions1536Vector size
search.vectors.metriccosinecosine, dot or euclidean
search.indexes.<index>.synonyms{}Synonyms per index, both ways: { "acme" = ["acme corp"] }
search.indexes.<index>.stop_words[]Words the index ignores in queries

The Search service

MethodReturns
query::<E>(text)A query builder: filter, filter_not, filter_in, range, filter_null, filter_by, sort_asc, sort_desc, accessible, workspace, hybrid, highlight_tags, then paginate(page), hits(page), ids(limit) or count()
similar::<E>(text), similar_to::<E>(id)A vector query builder (filter.., accessible, workspace, limit), run with run()
across(text)A multi-index query: include::<E>(scope), limit, workspace, run()
reindex::<E>(ids)Queues a sync for those records, or syncs them now unless sync = "queued"
import::<E>(), import_fresh::<E>()Indexes every row (into a new index swapped in, for fresh)
flush::<E>(), push_settings::<E>()Empties an index; pushes its settings to the engine
status()Every index with its engine, documents, rows and pending jobs
without_syncing(future)Runs the future with change syncing paused
flush_pending()Waits for inline syncs (tests)
assert_indexed::<E>(id), assert_not_indexed::<E>(id)Panics unless the index holds (or lacks) the record (tests)

Errors

A search that the engine can't answer returns SearchError. With ? in a handler it becomes Navree's error shape: an invalid filter or sort field, or a filter the engine can't run, is a 400; an unreachable or timed-out engine is a 503:

JSON

The sync jobs log every failure at target navree::search with the index, the ids and the engine's answer, and retry.