Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/ja/src/concepts/search/lexical_search.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,26 @@ let config = LexicalIndexConfig::builder()
.build();
```

## パース済みクエリキャッシュ(Parsed Query Cache)

DSL 文字列での検索(`SearchRequest::from_dsl` や `LexicalSearchQuery::Dsl`)は、毎回 pest
文法でパースし、語を analyzer で再トークン化します。オートコンプリートや人気クエリでは同じ
文字列が繰り返されるため、laurus は `DSL 文字列 → パース済みクエリ` をメモ化します。繰り返し
の DSL 文字列は一度だけパースされ、以降は再利用されます(パース済みクエリツリーの安価な複製)。

フィルタキャッシュと同様に**スナップショット連動**です。キャッシュは searcher 上に存在し、
`commit()` / `optimize()` / `refresh()` のたびに再構築されます。analyzer と default fields は
その searcher で固定なので DSL 文字列のみがキーになり、スキーマ/analyzer 変更時は空の新しい
キャッシュになります。デフォルトで有効。インデックス設定で調整・無効化できます。

```rust
use laurus::lexical::store::config::LexicalIndexConfig;

let config = LexicalIndexConfig::builder()
.parsed_query_cache_capacity(2048) // スナップショットあたりのエントリ数。0 で無効化
.build();
```

## 次のステップ

- 意味的類似性検索: [Vector 検索](vector_search.md)
Expand Down
21 changes: 21 additions & 0 deletions docs/src/concepts/search/lexical_search.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,27 @@ let config = LexicalIndexConfig::builder()
.build();
```

## Parsed Query Cache

Searching with a DSL string (`SearchRequest::from_dsl`, or a `LexicalSearchQuery::Dsl`)
parses the string with the pest grammar and re-tokenises its terms with the analyzer on
every call. Autocomplete and popular-query workloads repeat the same strings, so Laurus
memoises `dsl string → parsed query`: a repeated DSL string is parsed once and then reused
(a cheap clone of the parsed query tree).

Like the filter cache, it is **snapshot-scoped**: the cache lives on the searcher, which is
rebuilt on every `commit()` / `optimize()` / `refresh()`. The analyzer and default fields are
fixed for that searcher, so the DSL string alone is the key; a schema/analyzer change yields a
fresh, empty cache. Enabled by default; tune or disable via the index config:

```rust
use laurus::lexical::store::config::LexicalIndexConfig;

let config = LexicalIndexConfig::builder()
.parsed_query_cache_capacity(2048) // entries per snapshot; 0 disables the cache
.build();
```

## Next Steps

- Semantic similarity search: [Vector Search](vector_search.md)
Expand Down
18 changes: 18 additions & 0 deletions laurus/src/lexical/index/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ pub struct InvertedIndexConfig {
/// of on every request. `0` disables the cache. Defaults to `1024`.
#[serde(default = "default_query_filter_cache_capacity")]
pub query_filter_cache_capacity: usize,

/// Maximum number of entries in the snapshot-scoped parsed-DSL query cache
/// (Issue #590).
///
/// A repeated DSL query string is parsed once per searcher snapshot and
/// reused, avoiding the pest parse + analyzer tokenisation on every call.
/// `0` disables the cache. Defaults to `1024`.
#[serde(default = "default_parsed_query_cache_capacity")]
pub parsed_query_cache_capacity: usize,
}

fn default_analyzer() -> Arc<dyn Analyzer> {
Expand All @@ -92,6 +101,10 @@ fn default_query_filter_cache_capacity() -> usize {
1024
}

fn default_parsed_query_cache_capacity() -> usize {
1024
}

impl Default for InvertedIndexConfig {
fn default() -> Self {
InvertedIndexConfig {
Expand All @@ -109,6 +122,7 @@ impl Default for InvertedIndexConfig {
shard_id: 0,
fields: HashMap::new(),
query_filter_cache_capacity: default_query_filter_cache_capacity(),
parsed_query_cache_capacity: default_parsed_query_cache_capacity(),
}
}
}
Expand All @@ -128,6 +142,10 @@ impl std::fmt::Debug for InvertedIndexConfig {
"query_filter_cache_capacity",
&self.query_filter_cache_capacity,
)
.field(
"parsed_query_cache_capacity",
&self.parsed_query_cache_capacity,
)
.finish()
}
}
4 changes: 3 additions & 1 deletion laurus/src/lexical/index/inverted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ use crate::storage::file::{FileStorage, FileStorageConfig};
pub(crate) mod bmw;
pub mod core;
pub mod maintenance;
pub mod parsed_query_cache;
pub(crate) mod per_segment_view;
pub mod query_cache;
pub mod reader;
Expand Down Expand Up @@ -585,7 +586,8 @@ impl LexicalIndex for InvertedIndex {
self.check_closed()?;
let reader = self.reader()?;
let searcher = InvertedIndexSearcher::from_arc(reader)
.with_default_fields(self.config.default_fields.clone());
.with_default_fields(self.config.default_fields.clone())
.with_parsed_query_cache_capacity(self.config.parsed_query_cache_capacity);
Ok(Box::new(searcher))
}

Expand Down
173 changes: 173 additions & 0 deletions laurus/src/lexical/index/inverted/parsed_query_cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//! Parsed-query (DSL) cache (Issue
//! [#590](https://github.com/mosuka/laurus/issues/590)).
//!
//! Every `LexicalSearchQuery::Dsl(string)` is otherwise re-parsed on each
//! search — the pest grammar runs and the analyzer re-tokenises the query
//! terms. Autocomplete / popular-query servers pay that cost per call.
//! [`ParsedQueryCache`] memoises `dsl string -> Arc<dyn Query>` so a repeated
//! DSL string is parsed once and then reused via
//! [`Query::clone_box`](crate::lexical::query::Query::clone_box) (a cheap
//! refcount bump for boolean clause subtrees, #413).
//!
//! # Lifetime and key
//!
//! The cache lives on
//! [`InvertedIndexSearcher`](crate::lexical::index::inverted::searcher::InvertedIndexSearcher),
//! which the store rebuilds on every `commit()` / `optimize()` / `refresh()`.
//! The analyzer and `default_fields` are fixed for that searcher's lifetime, so
//! the DSL string alone keys the cache; a schema / analyzer change yields a
//! fresh searcher with an empty cache (no manual invalidation).

use std::num::NonZeroUsize;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};

use lru::LruCache;
use parking_lot::Mutex;

use crate::lexical::query::Query;

/// A bounded LRU cache mapping a DSL query string to its parsed
/// `Arc<dyn Query>` for one searcher snapshot.
///
/// A [`Mutex`] (not an `RwLock`) guards the map because [`LruCache::get`] takes
/// `&mut self` to update recency; the critical section is a single map probe
/// plus an [`Arc`] clone. When the configured capacity is zero the cache is
/// disabled and every operation is a no-op.
#[derive(Debug)]
pub struct ParsedQueryCache {
/// The LRU map, or `None` when caching is disabled (capacity 0).
inner: Option<Mutex<LruCache<String, Arc<dyn Query>>>>,
/// Number of lookups served from the cache.
hits: AtomicU64,
/// Number of lookups that missed (including lookups while disabled).
misses: AtomicU64,
}

impl ParsedQueryCache {
/// Create a cache holding up to `capacity` parsed queries.
///
/// A `capacity` of `0` disables the cache: [`get`](Self::get) always misses
/// and [`put`](Self::put) is a no-op, so callers always parse fresh.
///
/// # Arguments
///
/// * `capacity` - Maximum number of `(dsl string, parsed query)` entries to
/// retain. The least-recently-used entry is evicted when full.
pub fn new(capacity: usize) -> Self {
let inner = NonZeroUsize::new(capacity).map(|c| Mutex::new(LruCache::new(c)));
ParsedQueryCache {
inner,
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
}
}

/// Look up the parsed query cached for `dsl`, bumping its recency on a hit.
///
/// Records a hit or miss in the statistics. Returns `None` when the DSL
/// string is absent or the cache is disabled; the cloned [`Arc`] on a hit
/// is a refcount bump, not a re-parse.
///
/// # Arguments
///
/// * `dsl` - The DSL query string.
pub fn get(&self, dsl: &str) -> Option<Arc<dyn Query>> {
let hit = self
.inner
.as_ref()
.and_then(|inner| inner.lock().get(dsl).cloned());
if hit.is_some() {
self.hits.fetch_add(1, Ordering::Relaxed);
} else {
self.misses.fetch_add(1, Ordering::Relaxed);
}
hit
}

/// Insert a parsed query for `dsl`, evicting the least-recently-used entry
/// when full. A no-op when the cache is disabled.
///
/// # Arguments
///
/// * `dsl` - The DSL query string.
/// * `query` - The parsed query to share.
pub fn put(&self, dsl: String, query: Arc<dyn Query>) {
if let Some(inner) = self.inner.as_ref() {
inner.lock().put(dsl, query);
}
}

/// Returns `true` if caching is enabled (capacity was non-zero).
pub fn is_enabled(&self) -> bool {
self.inner.is_some()
}

/// Snapshot of the cache hit / miss counters.
pub fn stats(&self) -> ParsedQueryCacheStats {
ParsedQueryCacheStats {
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
}
}
}

/// Hit / miss counters for a [`ParsedQueryCache`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParsedQueryCacheStats {
/// Number of lookups served from the cache.
pub hits: u64,
/// Number of lookups that had to parse the DSL.
pub misses: u64,
}

#[cfg(test)]
mod tests {
use super::*;
use crate::lexical::query::term::TermQuery;

fn term(field: &str, t: &str) -> Arc<dyn Query> {
Arc::new(TermQuery::new(field, t))
}

#[test]
fn put_then_get_returns_cached_query() {
let cache = ParsedQueryCache::new(4);
cache.put("title:rust".to_string(), term("title", "rust"));

let got = cache.get("title:rust").expect("entry should be present");
assert_eq!(got.description(), "title:rust");
assert_eq!(cache.stats().hits, 1);
assert_eq!(cache.stats().misses, 0);
}

#[test]
fn miss_increments_miss_counter() {
let cache = ParsedQueryCache::new(4);
assert!(cache.get("absent:x").is_none());
assert_eq!(cache.stats().misses, 1);
assert_eq!(cache.stats().hits, 0);
}

#[test]
fn capacity_zero_disables_cache() {
let cache = ParsedQueryCache::new(0);
assert!(!cache.is_enabled());
cache.put("title:rust".to_string(), term("title", "rust"));
assert!(cache.get("title:rust").is_none());
}

#[test]
fn lru_evicts_least_recently_used() {
let cache = ParsedQueryCache::new(2);
cache.put("a:1".to_string(), term("a", "1"));
cache.put("b:1".to_string(), term("b", "1"));
// Touch "a:1" so "b:1" becomes the LRU victim.
assert!(cache.get("a:1").is_some());
cache.put("c:1".to_string(), term("c", "1"));

assert!(cache.get("a:1").is_some(), "recently used");
assert!(cache.get("c:1").is_some(), "just inserted");
assert!(cache.get("b:1").is_none(), "should have been evicted");
}
}
Loading
Loading