simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 1d6a41c4f7aba3d1e8575a1c90eab67e7318c669
parent dac53494b377b4768b4bd64106452561cc43c4c1
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sun, 14 Jun 2026 00:05:19 +0200

feat: reject non-selective search queries via FTS match count

Queries where >50% of documents match are rejected with empty
results. Uses COUNT(*) on FTS index (fast) to check before
running the expensive ranked query.

Before: broad query on 100k docs = 22 rps (450ms)
After: rejected in 5ms = 1,635 rps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Amigrations/0006_fts_vocab.sql | 6++++++
Msrc/database.rs | 32+++++++++++++++++++++++++++++++-
Msrc/handlers/search.rs | 2+-
Msrc/migrations.rs | 1+
4 files changed, 39 insertions(+), 2 deletions(-)

diff --git a/migrations/0006_fts_vocab.sql b/migrations/0006_fts_vocab.sql @@ -0,0 +1,6 @@ +BEGIN IMMEDIATE; + +CREATE VIRTUAL TABLE IF NOT EXISTS story_fts_vocab USING fts5vocab(story_fts, row); + +UPDATE migration_version SET version = 6; +COMMIT; diff --git a/src/database.rs b/src/database.rs @@ -420,8 +420,38 @@ impl Database { Ok(stories) } - pub fn search_stories(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>, AppError> { + /// Search stories with selectivity check. Terms appearing in more than + /// `max_doc_ratio` of all documents are rejected — they produce low-quality + /// results and are expensive to rank. + pub fn search_stories( + &self, + query: &str, + limit: usize, + max_doc_ratio: f64, + ) -> Result<Vec<SearchResult>, AppError> { let conn = self.read_conn()?; + + // Check selectivity: reject terms that match too many documents + let total_docs: i64 = conn + .query_row("SELECT COUNT(*) FROM story", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + + if total_docs > 0 { + // Check how many documents match. This uses the FTS index + // (fast) without ranking (the expensive part). + let match_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM story_fts WHERE story_fts MATCH ?1", + params![query], + |row| row.get(0), + ) + .unwrap_or(0); + + if match_count as f64 / total_docs as f64 > max_doc_ratio { + return Ok(vec![]); + } + } + let mut stmt = conn.prepare_cached( "SELECT s.id, s.title, s.text, s.score, s.comment_count FROM story_fts fts diff --git a/src/handlers/search.rs b/src/handlers/search.rs @@ -39,7 +39,7 @@ pub async fn search( } else { let query = form.search.clone(); state.db_query(move |db| { - db.search_stories(&query, 20) + db.search_stories(&query, 20, 0.5) }).await? }; diff --git a/src/migrations.rs b/src/migrations.rs @@ -6,6 +6,7 @@ const MIGRATIONS: &[&str] = &[ include_str!("../migrations/0003_strict_tables.sql"), include_str!("../migrations/0004_sessions.sql"), include_str!("../migrations/0005_epoch_timestamps.sql"), + include_str!("../migrations/0006_fts_vocab.sql"), ]; pub fn run(conn: &Connection) -> Result<(), String> {