use std::collections::BTreeMap;
use std::future::Future;
use std::sync::Arc;
use autumn_web::authorization::PolicyContext;
use autumn_web::pagination::{ListQuery, Page, PageRequest};
use autumn_web::search::{IndexDefinition, SearchDocument, SearchIndexed};
use crate::authz::{SearchVisibility, ambient_tenant_filter};
use crate::backend::{
IndexedDocument, KeywordQuery, SearchBackend, SearchFilter, SearchHit, TENANT_FILTER_KEY,
VectorQuery, empty_page,
};
use crate::embedding::{Embedder, NoEmbedder};
use crate::error::{SearchError, SearchResult};
use crate::jobs::{ReindexArgs, ReindexOp};
use crate::source::DocumentSource;
const DEFAULT_BACKFILL_BATCH: usize = 500;
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct BackfillOptions {
pub batch_size: usize,
pub purge: bool,
}
impl Default for BackfillOptions {
fn default() -> Self {
Self {
batch_size: DEFAULT_BACKFILL_BATCH,
purge: false,
}
}
}
impl BackfillOptions {
#[must_use]
pub const fn batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
#[must_use]
pub const fn purge(mut self, purge: bool) -> Self {
self.purge = purge;
self
}
#[must_use]
pub const fn effective_batch_size(&self) -> usize {
if self.batch_size == 0 {
DEFAULT_BACKFILL_BATCH
} else {
self.batch_size
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BackfillReport {
pub index: String,
pub indexed: u64,
pub batches: u64,
pub purged: bool,
}
#[derive(Clone)]
pub struct SearchClient {
inner: Arc<ClientInner>,
}
struct ClientInner {
backend: Arc<dyn SearchBackend>,
embedder: Arc<dyn Embedder>,
source: Option<Arc<dyn DocumentSource>>,
visibility: Option<Arc<dyn SearchVisibility>>,
indexes: BTreeMap<String, IndexDefinition>,
enabled: bool,
batch_size: usize,
}
impl std::fmt::Debug for SearchClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SearchClient")
.field("backend", &self.inner.backend.name())
.field("indexes", &self.index_names())
.field("embedding_dimensions", &self.inner.embedder.dimensions())
.field("has_source", &self.inner.source.is_some())
.field("has_visibility", &self.inner.visibility.is_some())
.field("enabled", &self.inner.enabled)
.finish()
}
}
pub struct SearchClientBuilder {
backend: Option<Arc<dyn SearchBackend>>,
embedder: Option<Arc<dyn Embedder>>,
source: Option<Arc<dyn DocumentSource>>,
visibility: Option<Arc<dyn SearchVisibility>>,
indexes: BTreeMap<String, IndexDefinition>,
enabled: bool,
batch_size: usize,
}
impl Default for SearchClientBuilder {
fn default() -> Self {
Self {
backend: None,
embedder: None,
source: None,
visibility: None,
indexes: BTreeMap::new(),
enabled: true,
batch_size: DEFAULT_BACKFILL_BATCH,
}
}
}
impl SearchClientBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn backend(mut self, backend: Arc<dyn SearchBackend>) -> Self {
self.backend = Some(backend);
self
}
#[must_use]
pub fn embedder(mut self, embedder: Arc<dyn Embedder>) -> Self {
self.embedder = Some(embedder);
self
}
#[must_use]
pub fn source(mut self, source: Arc<dyn DocumentSource>) -> Self {
self.source = Some(source);
self
}
#[must_use]
pub fn visibility(mut self, visibility: Arc<dyn SearchVisibility>) -> Self {
self.visibility = Some(visibility);
self
}
#[must_use]
pub fn index<M: SearchIndexed>(mut self) -> Self {
let definition = M::index_definition();
self.indexes.insert(definition.name.to_owned(), definition);
self
}
#[must_use]
pub fn index_definition(mut self, definition: IndexDefinition) -> Self {
self.indexes.insert(definition.name.to_owned(), definition);
self
}
#[must_use]
pub const fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
#[must_use]
pub const fn batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = if batch_size == 0 {
DEFAULT_BACKFILL_BATCH
} else {
batch_size
};
self
}
#[must_use]
pub fn build(self) -> SearchClient {
SearchClient {
inner: Arc::new(ClientInner {
backend: self
.backend
.unwrap_or_else(|| Arc::new(crate::memory::MemorySearchBackend::new())),
embedder: self.embedder.unwrap_or_else(|| Arc::new(NoEmbedder)),
source: self.source,
visibility: self.visibility,
indexes: self.indexes,
enabled: self.enabled,
batch_size: self.batch_size,
}),
}
}
}
impl SearchClient {
#[must_use]
pub fn builder() -> SearchClientBuilder {
SearchClientBuilder::new()
}
#[must_use]
pub fn backend(&self) -> &Arc<dyn SearchBackend> {
&self.inner.backend
}
#[must_use]
pub fn is_enabled(&self) -> bool {
self.inner.enabled
}
#[must_use]
pub fn default_batch_size(&self) -> usize {
self.inner.batch_size
}
#[must_use]
pub fn embedding_dimensions(&self) -> usize {
self.inner.embedder.dimensions()
}
#[must_use]
pub fn index_names(&self) -> Vec<String> {
self.inner.indexes.keys().cloned().collect()
}
#[must_use]
pub fn index_definition(&self, name: &str) -> Option<&IndexDefinition> {
self.inner.indexes.get(name)
}
fn definition(&self, name: &str) -> SearchResult<&IndexDefinition> {
self.inner
.indexes
.get(name)
.ok_or_else(|| SearchError::UnknownIndex(name.to_owned()))
}
pub async fn ensure_indexes(&self) -> SearchResult<()> {
for definition in self.inner.indexes.values() {
self.inner.backend.ensure_index(definition).await?;
}
Ok(())
}
pub async fn index_record<M: SearchIndexed>(&self, record: &M) -> SearchResult<()> {
self.index_documents(M::SEARCH_INDEX, vec![record.search_document()])
.await
}
pub async fn index_documents(
&self,
index: &str,
documents: Vec<SearchDocument>,
) -> SearchResult<()> {
if !self.inner.enabled || documents.is_empty() {
return Ok(());
}
let definition = self.definition(index)?;
let prepared = self.embed_documents(documents).await?;
self.inner.backend.index(definition, &prepared).await
}
pub async fn remove<M: SearchIndexed>(&self, id: i64) -> SearchResult<()> {
self.remove_ids(M::SEARCH_INDEX, &[id]).await
}
pub async fn remove_ids(&self, index: &str, ids: &[i64]) -> SearchResult<()> {
if !self.inner.enabled || ids.is_empty() {
return Ok(());
}
let definition = self.definition(index)?;
self.inner.backend.delete(definition, ids).await
}
async fn embed_documents(
&self,
documents: Vec<SearchDocument>,
) -> SearchResult<Vec<IndexedDocument>> {
if self.inner.embedder.dimensions() == 0 {
return Ok(documents.into_iter().map(IndexedDocument::new).collect());
}
let mut positions = Vec::new();
let mut texts = Vec::new();
for (position, document) in documents.iter().enumerate() {
if let Some(text) = &document.embed_text {
positions.push(position);
texts.push(text.clone());
}
}
let mut prepared: Vec<IndexedDocument> =
documents.into_iter().map(IndexedDocument::new).collect();
if texts.is_empty() {
return Ok(prepared);
}
let vectors = self.inner.embedder.embed(&texts).await?;
if vectors.len() != positions.len() {
return Err(SearchError::Embedding(format!(
"embedder returned {} vectors for {} inputs",
vectors.len(),
positions.len()
)));
}
for (position, vector) in positions.into_iter().zip(vectors) {
if let Some(document) = prepared.get_mut(position) {
document.embedding = Some(vector);
}
}
Ok(prepared)
}
pub async fn search<M: SearchIndexed>(
&self,
query: &str,
request: &PageRequest,
) -> SearchResult<Page<SearchHit>> {
self.search_filtered::<M>(query, request, SearchFilter::default())
.await
}
pub async fn search_filtered<M: SearchIndexed>(
&self,
query: &str,
request: &PageRequest,
filter: SearchFilter,
) -> SearchResult<Page<SearchHit>> {
if !self.inner.enabled {
return Ok(empty_page(request));
}
let definition = self.definition(M::SEARCH_INDEX)?;
let filter = ambient_tenant_filter(definition)?.intersect(filter);
let keyword = KeywordQuery::new(query, *request).filter(filter);
self.inner
.backend
.keyword_search(definition, &keyword)
.await
}
pub async fn search_list<M: SearchIndexed>(
&self,
query: &str,
list: &ListQuery,
request: &PageRequest,
) -> SearchResult<Page<SearchHit>> {
if !self.inner.enabled {
return Ok(empty_page(request));
}
let definition = self.definition(M::SEARCH_INDEX)?;
let filter = filter_from_list_query(definition, list);
self.search_filtered::<M>(query, request, filter).await
}
pub async fn search_for<M: SearchIndexed>(
&self,
ctx: &PolicyContext,
query: &str,
request: &PageRequest,
) -> SearchResult<Page<SearchHit>> {
if !self.inner.enabled {
return Ok(empty_page(request));
}
let filter = self.visibility_filter(ctx, M::SEARCH_INDEX).await?;
self.search_filtered::<M>(query, request, filter).await
}
pub async fn search_hydrated<M, F, Fut>(
&self,
query: &str,
request: &PageRequest,
loader: F,
) -> SearchResult<Page<M>>
where
M: SearchIndexed,
F: FnOnce(Vec<i64>) -> Fut + Send,
Fut: Future<Output = SearchResult<Vec<M>>> + Send,
{
let hits = self.search::<M>(query, request).await?;
self.hydrate(hits, loader).await
}
pub async fn hydrate<M, F, Fut>(
&self,
hits: Page<SearchHit>,
loader: F,
) -> SearchResult<Page<M>>
where
M: SearchIndexed,
F: FnOnce(Vec<i64>) -> Fut + Send,
Fut: Future<Output = SearchResult<Vec<M>>> + Send,
{
let order: Vec<i64> = hits.content.iter().map(|hit| hit.id).collect();
let request = PageRequest::new(hits.page, hits.size);
let records = loader(order.clone()).await?;
let mut by_id: BTreeMap<i64, M> = records
.into_iter()
.map(|record| (record.search_id(), record))
.collect();
let content: Vec<M> = order.iter().filter_map(|id| by_id.remove(id)).collect();
let dropped = u64::try_from(order.len().saturating_sub(content.len())).unwrap_or(0);
let total = i64::try_from(hits.total_elements.saturating_sub(dropped)).unwrap_or(i64::MAX);
Ok(Page::new(content, total, &request))
}
pub async fn similar<M: SearchIndexed>(
&self,
text: &str,
limit: usize,
) -> SearchResult<Vec<SearchHit>> {
self.similar_filtered::<M>(text, limit, SearchFilter::default())
.await
}
pub async fn similar_filtered<M: SearchIndexed>(
&self,
text: &str,
limit: usize,
filter: SearchFilter,
) -> SearchResult<Vec<SearchHit>> {
if !self.inner.enabled {
return Ok(Vec::new());
}
if self.inner.embedder.dimensions() == 0 {
return Err(SearchError::EmbedderUnavailable);
}
let vector = self
.inner
.embedder
.embed(std::slice::from_ref(&text.to_owned()))
.await?
.into_iter()
.next()
.ok_or_else(|| {
SearchError::Embedding("embedder returned no vector for the query".to_owned())
})?;
self.similar_to_vector::<M>(vector, limit, filter).await
}
pub async fn similar_for<M: SearchIndexed>(
&self,
ctx: &PolicyContext,
text: &str,
limit: usize,
) -> SearchResult<Vec<SearchHit>> {
if !self.inner.enabled {
return Ok(Vec::new());
}
let filter = self.visibility_filter(ctx, M::SEARCH_INDEX).await?;
self.similar_filtered::<M>(text, limit, filter).await
}
pub async fn similar_to<M: SearchIndexed>(
&self,
id: i64,
limit: usize,
) -> SearchResult<Vec<SearchHit>> {
self.similar_to_filtered::<M>(id, limit, SearchFilter::default())
.await
}
pub async fn similar_to_for<M: SearchIndexed>(
&self,
ctx: &PolicyContext,
id: i64,
limit: usize,
) -> SearchResult<Vec<SearchHit>> {
if !self.inner.enabled {
return Ok(Vec::new());
}
let filter = self.visibility_filter(ctx, M::SEARCH_INDEX).await?;
self.similar_to_filtered::<M>(id, limit, filter).await
}
pub async fn similar_to_filtered<M: SearchIndexed>(
&self,
id: i64,
limit: usize,
filter: SearchFilter,
) -> SearchResult<Vec<SearchHit>> {
if !self.inner.enabled {
return Ok(Vec::new());
}
let definition = self.definition(M::SEARCH_INDEX)?;
let seed_filter = ambient_tenant_filter(definition)?.intersect(filter.clone());
let Some(vector) = self
.inner
.backend
.embedding(definition, id, &seed_filter)
.await?
else {
return Ok(Vec::new());
};
self.similar_to_vector::<M>(vector, limit, filter.exclude_ids([id]))
.await
}
pub async fn similar_to_vector<M: SearchIndexed>(
&self,
vector: Vec<f32>,
limit: usize,
filter: SearchFilter,
) -> SearchResult<Vec<SearchHit>> {
if !self.inner.enabled {
return Ok(Vec::new());
}
let definition = self.definition(M::SEARCH_INDEX)?;
let filter = ambient_tenant_filter(definition)?.intersect(filter);
let query = VectorQuery::new(vector, limit).filter(filter);
self.inner.backend.vector_search(definition, &query).await
}
async fn visibility_filter(
&self,
ctx: &PolicyContext,
index: &str,
) -> SearchResult<SearchFilter> {
let visibility = self
.inner
.visibility
.as_ref()
.ok_or(SearchError::VisibilityUnavailable)?;
visibility.filter(ctx, index).await
}
pub async fn reindex(&self, args: &ReindexArgs) -> SearchResult<()> {
if !self.inner.enabled {
return Ok(());
}
let definition = self.definition(&args.index)?;
let Some(source) = self.inner.source.as_ref() else {
return match args.op {
ReindexOp::Delete => self.inner.backend.delete(definition, &[args.id]).await,
ReindexOp::Upsert => Err(SearchError::SourceUnavailable),
};
};
let documents = source.fetch(definition, &[args.id]).await?;
if documents.is_empty() {
return self.inner.backend.delete(definition, &[args.id]).await;
}
let prepared = self.embed_documents(documents).await?;
self.inner.backend.index(definition, &prepared).await
}
pub async fn backfill(
&self,
index: &str,
options: &BackfillOptions,
) -> SearchResult<BackfillReport> {
if !self.inner.enabled {
return Ok(BackfillReport {
index: index.to_owned(),
indexed: 0,
batches: 0,
purged: false,
});
}
let definition = self.definition(index)?;
let source = self
.inner
.source
.as_ref()
.ok_or(SearchError::SourceUnavailable)?;
if options.purge {
self.inner.backend.clear(definition).await?;
}
let batch_size = options.effective_batch_size();
let mut report = BackfillReport {
index: index.to_owned(),
indexed: 0,
batches: 0,
purged: options.purge,
};
let mut after: Option<i64> = None;
let watermark = self.inner.backend.write_watermark().await?;
if watermark.is_none() && !self.inner.backend.capabilities().conditional_index {
tracing::debug!(
backend = self.inner.backend.name(),
index = %index,
"backend has no write watermark; a concurrent reindex may be overwritten by this \
backfill"
);
}
loop {
let documents = source.scan(definition, after, batch_size).await?;
if documents.is_empty() {
break;
}
after = documents.last().map(|document| document.id);
let count = documents.len() as u64;
let prepared = self.embed_documents(documents).await?;
self.inner
.backend
.index_unless_newer(definition, &prepared, watermark.as_deref())
.await?;
report.indexed = report.indexed.saturating_add(count);
report.batches = report.batches.saturating_add(1);
}
tracing::info!(
index = %report.index,
indexed = report.indexed,
batches = report.batches,
purged = report.purged,
"search backfill complete"
);
Ok(report)
}
pub async fn backfill_all(
&self,
options: &BackfillOptions,
) -> SearchResult<Vec<BackfillReport>> {
let mut reports = Vec::new();
for index in self.index_names() {
reports.push(self.backfill(&index, options).await?);
}
Ok(reports)
}
}
fn filter_from_list_query(definition: &IndexDefinition, list: &ListQuery) -> SearchFilter {
let mut filter = SearchFilter::default();
for (key, value) in list.filters() {
if key == TENANT_FILTER_KEY {
filter = filter.tenant(value);
} else if definition.field_names().any(|field| field == key) {
filter = filter.equals(key, value);
}
}
filter
}
#[cfg(test)]
mod tests {
use autumn_web::pagination::SortDir;
use autumn_web::search::SearchIndexField;
use super::*;
const FIELDS: &[SearchIndexField] = &[
SearchIndexField::new("title", 'A'),
SearchIndexField::new("body", 'B'),
];
fn definition() -> IndexDefinition {
IndexDefinition::new("articles", "english", FIELDS, Some("body"), false)
}
#[test]
fn list_query_filters_map_onto_indexed_fields() {
let list = ListQuery::new(None, SortDir::Asc, &[("title", "Hello")]);
let filter = filter_from_list_query(&definition(), &list);
assert_eq!(
filter.equals.get("title").map(String::as_str),
Some("Hello")
);
}
#[test]
fn the_reserved_tenant_key_becomes_a_tenant_restriction() {
let list = ListQuery::new(None, SortDir::Asc, &[("tenant_id", "acme")]);
let filter = filter_from_list_query(&definition(), &list);
assert_eq!(filter.tenant_id.as_deref(), Some("acme"));
assert!(filter.equals.is_empty());
}
#[test]
fn an_unknown_filter_key_is_ignored_like_list() {
let list = ListQuery::new(None, SortDir::Asc, &[("nope", "x")]);
let filter = filter_from_list_query(&definition(), &list);
assert_eq!(filter, SearchFilter::default());
}
#[test]
fn a_default_builder_matches_a_new_one() {
let client = SearchClientBuilder::default().build();
assert!(
client.is_enabled(),
"a default builder must not be disabled"
);
assert_eq!(client.default_batch_size(), DEFAULT_BACKFILL_BATCH);
}
#[test]
fn backfill_options_clamp_a_zero_batch_size() {
assert_eq!(
BackfillOptions::default()
.batch_size(0)
.effective_batch_size(),
DEFAULT_BACKFILL_BATCH
);
assert_eq!(
BackfillOptions::default()
.batch_size(7)
.effective_batch_size(),
7
);
}
#[test]
fn a_client_defaults_to_the_memory_backend_and_no_embedder() {
let client = SearchClient::builder().build();
assert_eq!(client.backend().name(), "memory");
assert!(client.is_enabled());
assert!(client.index_names().is_empty());
}
}