use std::collections::HashMap;
use std::sync::RwLock;
use autumn_web::pagination::{Page, PageRequest};
use autumn_web::search::{IndexDefinition, weight_factor};
use crate::backend::{
BackendCapabilities, BoxFuture, IndexedDocument, KeywordQuery, SearchBackend, SearchFilter,
SearchHit, VectorQuery, empty_page,
};
use crate::embedding::cosine_similarity;
use crate::error::{SearchError, SearchResult};
use crate::text::{query_tokens, tokenize};
#[derive(Debug, Default)]
pub struct MemorySearchBackend {
store: RwLock<Store>,
}
#[derive(Debug, Clone)]
struct StoredDocument {
indexed: IndexedDocument,
field_tokens: Vec<HashMap<String, u32>>,
}
impl StoredDocument {
fn new(indexed: IndexedDocument) -> Self {
let field_tokens = indexed
.document
.fields
.iter()
.map(|field| {
let mut counts: HashMap<String, u32> = HashMap::new();
for token in tokenize(&field.value) {
counts
.entry(token)
.and_modify(|count| *count = count.saturating_add(1))
.or_insert(1);
}
counts
})
.collect();
Self {
indexed,
field_tokens,
}
}
}
#[derive(Debug, Default)]
struct Store {
documents: HashMap<String, HashMap<i64, StoredDocument>>,
next_sequence: u64,
sequences: HashMap<String, HashMap<i64, u64>>,
}
impl MemorySearchBackend {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn document_count(&self, index: &str) -> usize {
self.store
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.documents
.get(index)
.map_or(0, HashMap::len)
}
#[must_use]
pub fn index_names(&self) -> Vec<String> {
let mut names: Vec<String> = self
.store
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.documents
.keys()
.cloned()
.collect();
names.sort();
names
}
fn with_store<T>(&self, f: impl FnOnce(&mut Store) -> T) -> T {
let mut guard = self
.store
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
f(&mut guard)
}
fn with_index<T>(
&self,
definition: &IndexDefinition,
f: impl FnOnce(&mut HashMap<i64, StoredDocument>) -> T,
) -> T {
self.with_store(|store| {
f(store
.documents
.entry(definition.name.to_owned())
.or_default())
})
}
fn sequence(&self) -> u64 {
self.store
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.next_sequence
}
fn record_deletes(&self, definition: &IndexDefinition, ids: &[i64]) {
self.with_store(|store| {
let index = definition.name.to_owned();
store.documents.entry(index.clone()).or_default();
let sequences = store.sequences.entry(index.clone()).or_default();
let mut next = store.next_sequence;
for id in ids {
next = next.saturating_add(1);
sequences.insert(*id, next);
}
store.next_sequence = next;
if let Some(documents) = store.documents.get_mut(&index) {
for id in ids {
documents.remove(id);
}
}
});
}
fn write(
&self,
definition: &IndexDefinition,
documents: &[IndexedDocument],
watermark: Option<u64>,
) -> SearchResult<()> {
definition
.validate()
.map_err(|e| SearchError::InvalidIndex(e.to_string()))?;
let prepared: Vec<StoredDocument> = documents
.iter()
.map(|document| StoredDocument::new(document.clone()))
.collect();
self.with_store(|store| {
let index = definition.name.to_owned();
store.documents.entry(index.clone()).or_default();
let mut next = store.next_sequence;
let mut applied: Vec<(i64, u64)> = Vec::with_capacity(documents.len());
{
let sequences = store.sequences.entry(index.clone()).or_default();
for document in documents {
if let (Some(watermark), Some(touched)) =
(watermark, sequences.get(&document.id()))
&& *touched > watermark
{
continue;
}
next = next.saturating_add(1);
applied.push((document.id(), next));
}
for (id, sequence) in &applied {
sequences.insert(*id, *sequence);
}
}
store.next_sequence = next;
let applied_ids: std::collections::HashSet<i64> =
applied.iter().map(|(id, _)| *id).collect();
if let Some(map) = store.documents.get_mut(&index) {
for stored in prepared {
if !applied_ids.contains(&stored.indexed.id()) {
continue;
}
map.insert(stored.indexed.id(), stored);
}
}
});
Ok(())
}
fn read_index<T>(
&self,
definition: &IndexDefinition,
f: impl FnOnce(Option<&HashMap<i64, StoredDocument>>) -> T,
) -> T {
let guard = self
.store
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
f(guard.documents.get(definition.name))
}
}
#[allow(clippy::cast_precision_loss)]
fn score(
definition: &IndexDefinition,
document: &IndexedDocument,
field_tokens: &[HashMap<String, u32>],
tokens: &[String],
) -> Option<f32> {
let mut total = 0.0_f32;
let mut matched = vec![false; tokens.len()];
for (field, field_tokens) in document.document.fields.iter().zip(field_tokens) {
if field.value.is_empty() {
continue;
}
let Some(weight) = definition.weight_of(field.name) else {
continue;
};
let factor = weight_factor(weight);
for (index, query_token) in tokens.iter().enumerate() {
if let Some(&count) = field_tokens.get(query_token.as_str()) {
total = factor.mul_add(count as f32, total);
if let Some(slot) = matched.get_mut(index) {
*slot = true;
}
}
}
}
if matched.iter().all(|m| *m) && total > 0.0 {
Some(total)
} else {
None
}
}
fn hit_order(a: &SearchHit, b: &SearchHit) -> std::cmp::Ordering {
b.score.total_cmp(&a.score).then_with(|| a.id.cmp(&b.id))
}
fn sort_hits(hits: &mut [SearchHit]) {
hits.sort_by(hit_order);
}
fn sort_top_k(hits: &mut [SearchHit], k: usize) {
let len = hits.len();
if k == 0 || k >= len {
sort_hits(hits);
return;
}
hits.select_nth_unstable_by(k.saturating_sub(1), hit_order);
if let Some(prefix) = hits.get_mut(..k) {
prefix.sort_by(hit_order);
}
}
fn paginate(hits: Vec<SearchHit>, request: &PageRequest) -> Page<SearchHit> {
let total = i64::try_from(hits.len()).unwrap_or(i64::MAX);
let size = request.size() as usize;
let offset = (request.page().saturating_sub(1) as usize).saturating_mul(size);
let content = hits.into_iter().skip(offset).take(size).collect();
Page::new(content, total, request)
}
impl SearchBackend for MemorySearchBackend {
fn name(&self) -> &'static str {
"memory"
}
fn capabilities(&self) -> BackendCapabilities {
BackendCapabilities::default()
.with_vector(true)
.with_weighted_fields(true)
.with_embedding_readback(true)
.with_conditional_index(true)
}
fn ensure_index<'a>(
&'a self,
definition: &'a IndexDefinition,
) -> BoxFuture<'a, SearchResult<()>> {
Box::pin(async move {
definition
.validate()
.map_err(|e| SearchError::InvalidIndex(e.to_string()))?;
self.with_index(definition, |_| ());
Ok(())
})
}
fn index<'a>(
&'a self,
definition: &'a IndexDefinition,
documents: &'a [IndexedDocument],
) -> BoxFuture<'a, SearchResult<()>> {
Box::pin(async move { self.write(definition, documents, None) })
}
fn write_watermark(&self) -> BoxFuture<'_, SearchResult<Option<String>>> {
Box::pin(async move { Ok(Some(self.sequence().to_string())) })
}
fn index_unless_newer<'a>(
&'a self,
definition: &'a IndexDefinition,
documents: &'a [IndexedDocument],
watermark: Option<&'a str>,
) -> BoxFuture<'a, SearchResult<()>> {
Box::pin(async move {
self.write(
definition,
documents,
watermark.and_then(|w| w.parse().ok()),
)
})
}
fn delete<'a>(
&'a self,
definition: &'a IndexDefinition,
ids: &'a [i64],
) -> BoxFuture<'a, SearchResult<()>> {
Box::pin(async move {
self.record_deletes(definition, ids);
Ok(())
})
}
fn clear<'a>(&'a self, definition: &'a IndexDefinition) -> BoxFuture<'a, SearchResult<()>> {
Box::pin(async move {
self.with_store(|store| {
store
.documents
.entry(definition.name.to_owned())
.or_default()
.clear();
store.sequences.remove(definition.name);
});
Ok(())
})
}
fn keyword_search<'a>(
&'a self,
definition: &'a IndexDefinition,
query: &'a KeywordQuery,
) -> BoxFuture<'a, SearchResult<Page<SearchHit>>> {
Box::pin(async move {
let Some(tokens) = query_tokens(&query.text) else {
return Ok(empty_page(&query.page));
};
if query.filter.matches_nothing() {
return Ok(empty_page(&query.page));
}
let mut hits = self.read_index(definition, |index| {
let mut hits = Vec::new();
for stored in index.into_iter().flat_map(HashMap::values) {
if !query.filter.permits(&stored.indexed.document) {
continue;
}
if let Some(score) =
score(definition, &stored.indexed, &stored.field_tokens, &tokens)
{
hits.push(SearchHit::new(definition.name, stored.indexed.id(), score));
}
}
hits
});
let size = query.page.size() as usize;
let offset = (query.page.page().saturating_sub(1) as usize).saturating_mul(size);
let needed = offset.saturating_add(size).min(hits.len());
sort_top_k(&mut hits, needed);
Ok(paginate(hits, &query.page))
})
}
fn vector_search<'a>(
&'a self,
definition: &'a IndexDefinition,
query: &'a VectorQuery,
) -> BoxFuture<'a, SearchResult<Vec<SearchHit>>> {
Box::pin(async move {
if !definition.supports_vector_search() {
return Err(SearchError::VectorUnsupported {
index: definition.name.to_owned(),
});
}
if query.filter.matches_nothing() || query.limit == 0 {
return Ok(Vec::new());
}
let mut hits = Vec::new();
let mismatch = self.read_index(definition, |index| {
for stored in index.into_iter().flat_map(HashMap::values) {
let Some(embedding) = &stored.indexed.embedding else {
continue;
};
if !query.filter.permits(&stored.indexed.document) {
continue;
}
if embedding.len() != query.vector.len() {
return Some(SearchError::DimensionMismatch {
expected: embedding.len(),
actual: query.vector.len(),
});
}
let score = cosine_similarity(embedding, &query.vector);
if query.min_score.is_some_and(|min| score < min) {
continue;
}
hits.push(SearchHit::new(definition.name, stored.indexed.id(), score));
}
None
});
if let Some(error) = mismatch {
return Err(error);
}
sort_top_k(&mut hits, query.limit);
hits.truncate(query.limit);
Ok(hits)
})
}
fn embedding<'a>(
&'a self,
definition: &'a IndexDefinition,
id: i64,
filter: &'a SearchFilter,
) -> BoxFuture<'a, SearchResult<Option<Vec<f32>>>> {
Box::pin(async move {
Ok(self.read_index(definition, |index| {
index
.and_then(|documents| documents.get(&id))
.filter(|stored| filter.permits(&stored.indexed.document))
.and_then(|stored| stored.indexed.embedding.clone())
}))
})
}
}
#[cfg(test)]
mod tests {
use autumn_web::search::{SearchDocument, 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)
}
fn doc(id: i64, title: &str, body: &str) -> IndexedDocument {
IndexedDocument::new(
SearchDocument::new("articles", id)
.with_field("title", 'A', title)
.with_field("body", 'B', body),
)
}
fn score_doc(
definition: &IndexDefinition,
document: &IndexedDocument,
tokens: &[String],
) -> Option<f32> {
let stored = StoredDocument::new(document.clone());
score(definition, &stored.indexed, &stored.field_tokens, tokens)
}
#[test]
fn scoring_requires_every_query_token() {
let document = doc(1, "Rust web", "frameworks");
assert!(score_doc(&definition(), &document, &["rust".to_owned()]).is_some());
assert!(
score_doc(
&definition(),
&document,
&["rust".to_owned(), "web".to_owned()]
)
.is_some()
);
assert!(
score_doc(
&definition(),
&document,
&["rust".to_owned(), "gardening".to_owned()]
)
.is_none(),
"a missing token must veto the match"
);
}
#[test]
fn scoring_weights_a_title_hit_above_a_body_hit() {
let title_hit = score_doc(
&definition(),
&doc(1, "rust", "nothing"),
&["rust".to_owned()],
)
.expect("match");
let body_hit = score_doc(
&definition(),
&doc(2, "nothing", "rust"),
&["rust".to_owned()],
)
.expect("match");
assert!(title_hit > body_hit, "{title_hit} !> {body_hit}");
}
#[test]
fn scoring_accumulates_repeated_occurrences() {
let once =
score_doc(&definition(), &doc(1, "rust", ""), &["rust".to_owned()]).expect("match");
let twice = score_doc(
&definition(),
&doc(1, "rust rust", ""),
&["rust".to_owned()],
)
.expect("match");
assert!(twice > once);
}
#[test]
fn scoring_uses_the_definitions_weight_not_the_documents() {
let honest = doc(1, "rust", "");
let inflated = IndexedDocument::new(
SearchDocument::new("articles", 2)
.with_field("title", 'A', "")
.with_field("body", 'A', "rust"),
);
let honest_score =
score_doc(&definition(), &honest, &["rust".to_owned()]).expect("title match");
let inflated_score =
score_doc(&definition(), &inflated, &["rust".to_owned()]).expect("body match");
assert!(
honest_score > inflated_score,
"a document-declared weight must not promote a body hit: \
{honest_score} !> {inflated_score}"
);
}
#[test]
fn scoring_ignores_fields_the_index_does_not_declare() {
let smuggled = IndexedDocument::new(
SearchDocument::new("articles", 1)
.with_field("title", 'A', "")
.with_field("body", 'B', "")
.with_field("internal_notes", 'A', "rust"),
);
assert!(
score_doc(&definition(), &smuggled, &["rust".to_owned()]).is_none(),
"a field outside the index definition must not be searchable"
);
}
#[test]
fn sorting_breaks_score_ties_by_ascending_id() {
let mut hits = vec![
SearchHit::new("articles", 3, 1.0),
SearchHit::new("articles", 1, 1.0),
SearchHit::new("articles", 2, 5.0),
];
sort_hits(&mut hits);
assert_eq!(hits.iter().map(|h| h.id).collect::<Vec<_>>(), vec![2, 1, 3]);
}
#[test]
fn top_k_sort_matches_a_full_sorts_prefix() {
let make_hits = || {
vec![
SearchHit::new("a", 7, 2.0),
SearchHit::new("a", 3, 5.0),
SearchHit::new("a", 1, 5.0),
SearchHit::new("a", 9, 1.0),
SearchHit::new("a", 2, 5.0),
SearchHit::new("a", 5, 3.0),
SearchHit::new("a", 4, 1.0),
]
};
for k in 0..=make_hits().len() {
let mut full = make_hits();
sort_hits(&mut full);
let mut top_k = make_hits();
sort_top_k(&mut top_k, k);
assert_eq!(
top_k[..k].iter().map(|h| h.id).collect::<Vec<_>>(),
full[..k].iter().map(|h| h.id).collect::<Vec<_>>(),
"sort_top_k(.., {k}) prefix must match a full sort's first {k} elements"
);
}
}
#[test]
fn paginating_preserves_the_pre_slice_total() {
let hits: Vec<SearchHit> = (1..=5).map(|id| SearchHit::new("a", id, 1.0)).collect();
let page = paginate(hits, &PageRequest::new(2, 2));
assert_eq!(
page.content.iter().map(|h| h.id).collect::<Vec<_>>(),
vec![3, 4]
);
assert_eq!(page.total_elements, 5);
}
#[test]
fn paginating_past_the_end_is_empty_not_wrapped() {
let hits: Vec<SearchHit> = (1..=3).map(|id| SearchHit::new("a", id, 1.0)).collect();
let page = paginate(hits, &PageRequest::new(9, 2));
assert!(page.content.is_empty());
assert_eq!(page.total_elements, 3);
}
#[tokio::test]
async fn clearing_one_index_leaves_the_others_alone() {
let backend = MemorySearchBackend::new();
let articles = definition();
let mut notes = definition();
notes.name = "notes";
backend.ensure_index(&articles).await.expect("ensure");
backend.ensure_index(¬es).await.expect("ensure");
backend
.index(&articles, &[doc(1, "a", "b")])
.await
.expect("index");
backend
.index(¬es, &[doc(1, "a", "b")])
.await
.expect("index");
backend.clear(&articles).await.expect("clear");
assert_eq!(backend.document_count("articles"), 0);
assert_eq!(backend.document_count("notes"), 1);
assert_eq!(backend.index_names(), vec!["articles", "notes"]);
}
#[tokio::test]
async fn a_zero_limit_vector_query_does_no_work() {
let backend = MemorySearchBackend::new();
let definition = definition();
backend.ensure_index(&definition).await.expect("ensure");
let hits = backend
.vector_search(&definition, &VectorQuery::new(vec![1.0], 0))
.await
.expect("vector search");
assert!(hits.is_empty());
}
#[tokio::test]
async fn embedding_readback_returns_none_for_an_unknown_record() {
let backend = MemorySearchBackend::new();
let definition = definition();
backend.ensure_index(&definition).await.expect("ensure");
assert!(
backend
.embedding(&definition, 42, &SearchFilter::default())
.await
.expect("read")
.is_none()
);
}
#[tokio::test]
async fn embedding_readback_honours_the_filter() {
let backend = MemorySearchBackend::new();
let definition = definition();
backend.ensure_index(&definition).await.expect("ensure");
backend
.index(
&definition,
&[IndexedDocument::new(
SearchDocument::new("articles", 1)
.with_field("title", 'A', "secret")
.with_tenant("globex"),
)
.with_embedding(vec![1.0, 0.0])],
)
.await
.expect("index");
assert!(
backend
.embedding(&definition, 1, &SearchFilter::default().tenant("acme"))
.await
.expect("read")
.is_none(),
"another tenant's embedding must not be readable"
);
assert!(
backend
.embedding(&definition, 1, &SearchFilter::default().tenant("globex"))
.await
.expect("read")
.is_some()
);
}
#[tokio::test]
async fn an_impossible_filter_short_circuits_both_query_paths() {
let backend = MemorySearchBackend::new();
let definition = definition();
backend.ensure_index(&definition).await.expect("ensure");
backend
.index(
&definition,
&[doc(1, "rust", "rust").with_embedding(vec![1.0])],
)
.await
.expect("index");
let impossible = SearchFilter::default().allow_ids(Vec::<i64>::new());
let page = backend
.keyword_search(
&definition,
&KeywordQuery::new("rust", PageRequest::default()).filter(impossible.clone()),
)
.await
.expect("keyword");
assert!(page.content.is_empty());
let hits = backend
.vector_search(
&definition,
&VectorQuery::new(vec![1.0], 10).filter(impossible),
)
.await
.expect("vector");
assert!(hits.is_empty());
}
}