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:
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> {