autumn_search/lib.rs
1//! Keyword **and** vector search for `autumn-web` applications (issue #1191).
2//!
3//! Autumn already had full-text search *primitives* in core (#842): a
4//! `#[searchable]` model attribute, a `tsvector` column, and a
5//! `#[repository(searchable)]` `search()` method. What it did not have was a
6//! **search subsystem** — a way to declare a model searchable, keep an index
7//! in sync as records change, and query it through one engine-agnostic API,
8//! let alone semantic / vector search for "find similar" and RAG retrieval.
9//!
10//! This crate is that subsystem: the autumn-shaped answer to Laravel Scout,
11//! with vectors first-class rather than bolted on.
12//!
13//! # Quick start
14//!
15//! ```rust,ignore
16//! use std::sync::Arc;
17//! use autumn_search::{SearchPlugin, SearchSyncHooks};
18//!
19//! // 1. Mark the model searchable. `embed` nominates the field to embed.
20//! #[autumn_web::model]
21//! #[searchable(language = "english")]
22//! pub struct Article {
23//! #[id] pub id: i64,
24//! #[searchable(weight = "A")] pub title: String,
25//! #[searchable(weight = "B", embed)] pub body: String,
26//! }
27//!
28//! // 2. Keep the index in sync from the record lifecycle. `#[repository]`
29//! // takes a plain type NAME for `hooks`, so alias the generic first.
30//! type ArticleSearchHooks = SearchSyncHooks<Article, NewArticle, UpdateArticle>;
31//!
32//! #[autumn_web::repository(Article, hooks = ArticleSearchHooks, commit_hooks = true)]
33//! pub trait ArticleRepository {}
34//!
35//! // 3. Mount the plugin.
36//! autumn_web::app()
37//! .plugin(
38//! SearchPlugin::new()
39//! .postgres()
40//! .embedder(Arc::new(MyEmbedder))
41//! .index::<Article>(),
42//! )
43//! .run()
44//! .await;
45//!
46//! // 4. Query it. The plugin installs the client as an `AppState` extension.
47//! let search = state.extension::<SearchClient>().expect("SearchPlugin installed");
48//! let page = search.search::<Article>("rust web framework", &page_req).await?;
49//! let similar = search.similar::<Article>("how do I add auth?", 5).await?;
50//! ```
51//!
52//! Configuration comes from `[search]`, which the plugin resolves itself
53//! (base `autumn.toml` → `[profile.<name>.search]` → `autumn-<profile>.toml`
54//! → `AUTUMN_SEARCH__*`, the same layering core applies) unless the app passes
55//! `SearchPlugin::config(...)`.
56//!
57//! # How the pieces fit
58//!
59//! | Concern | Type | Notes |
60//! | --- | --- | --- |
61//! | Index shape | `autumn_web::search::IndexDefinition` | derived from `#[searchable]`, never hand-written |
62//! | Record extraction | `autumn_web::search::SearchDocument` | derived from `#[searchable]` |
63//! | Engine | [`SearchBackend`] | [`MemorySearchBackend`], [`PostgresSearchStore`], or yours |
64//! | Embeddings | [`Embedder`] | no model or vendor ships here, by design |
65//! | Index sync | [`SearchSyncHooks`] → [`ReindexArgs`] → `#[job]` | off-request and durable |
66//! | Reading records | [`DocumentSource`] | drives both reindex and backfill |
67//! | Authorization | [`SearchVisibility`] → [`SearchFilter`] | pushed *into* the engine query |
68//! | Querying | [`SearchClient`] | `Page`/`ListQuery`-shaped results |
69//!
70//! # Design notes worth knowing
71//!
72//! **Reindex re-reads the row.** A reindex job carries `(index, id)`, not the
73//! record. So create, update, delete, a replayed job, a lost event, and a row
74//! changed by direct SQL all converge to the same index state, and
75//! at-least-once delivery is safe by construction.
76//!
77//! **Filters are arguments, not afterthoughts.** [`SearchFilter`] is a
78//! required parameter of the backend query methods, so an engine cannot
79//! quietly return rows the caller may not see; filters *intersect*, so a
80//! caller can only ever narrow what authorization allowed.
81//!
82//! **Query text is a bag of words.** Every token must match, and operator
83//! syntax (`OR`, `field:`, `*`) is never interpreted. That keeps results
84//! consistent across engines and makes a hostile query structurally incapable
85//! of widening the result set.
86
87// autumn-panic-gate: request-path crate — production code path must be panic-free.
88// See CONTRIBUTING.md "Request-path panic gate". Justify exceptions with
89// #[allow(clippy::<lint>, reason = "…")] at the narrowest scope.
90#![cfg_attr(
91 not(test),
92 deny(
93 clippy::unwrap_used,
94 clippy::expect_used,
95 clippy::panic,
96 clippy::unreachable,
97 clippy::todo,
98 clippy::unimplemented,
99 clippy::indexing_slicing,
100 clippy::string_slice,
101 clippy::arithmetic_side_effects,
102 )
103)]
104
105mod authz;
106mod backend;
107mod client;
108mod config;
109mod embedding;
110mod error;
111mod jobs;
112mod memory;
113mod plugin;
114mod postgres;
115mod source;
116mod sync;
117mod text;
118
119pub use authz::{
120 DenyAll, SearchVisibility, TenantVisibility, ambient_tenant_filter, current_tenant_filter,
121};
122pub use backend::{
123 BackendCapabilities, BoxFuture, IndexedDocument, KeywordQuery, SearchBackend, SearchFilter,
124 SearchHit, TENANT_FILTER_KEY, VectorQuery, empty_page,
125};
126pub use client::{BackfillOptions, BackfillReport, SearchClient, SearchClientBuilder};
127pub use config::{SearchConfig, SearchConfigError};
128pub use embedding::{Embedder, HashingEmbedder, NoEmbedder, cosine_similarity, normalize};
129pub use error::{SearchError, SearchResult};
130pub use jobs::{
131 BACKFILL_JOB, BackfillArgs, REINDEX_JOB, ReindexArgs, ReindexOp, client_from_state,
132 search_job_infos,
133};
134pub use memory::MemorySearchBackend;
135pub use plugin::{
136 BACKFILL_ENV, BACKFILL_PURGE_ENV, BackfillTarget, SearchPlugin, backfill_request_from_env,
137};
138pub use postgres::{DOCUMENTS_TABLE, PostgresSearchStore, VectorMode};
139pub use source::{DocumentSource, MemoryDocumentSource};
140pub use sync::{SearchSyncHooks, enqueue_reindex, enqueue_reindex_for, enqueue_unindex_for};
141pub use text::{query_tokens, tokenize};
142
143// Re-exported so an app can name the derived vocabulary without also depending
144// on `autumn_web::search` directly.
145pub use autumn_web::search::{
146 IndexDefinition, SearchDocument, SearchFieldValue, SearchIndexField, SearchIndexed,
147 SearchTextValue,
148};
149
150// Results are `Page`-shaped and queries are `PageRequest`/`ListQuery`-shaped,
151// so re-export those too rather than making every call site reach into
152// `autumn_web::pagination`.
153pub use autumn_web::pagination::{ListQuery, Page, PageRequest};