From 246d8491639cb39cff9233ac1d9cd647108dcb4d Mon Sep 17 00:00:00 2001 From: Joshua Ryder Date: Mon, 10 Nov 2025 20:05:29 -0500 Subject: [PATCH] Enhance: Add exact match boosting to search relevance scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FTS5 with Porter stemming treats 'kindness' and 'kind' as the same root word, which caused stemmed matches to rank equally with exact matches. This adds a secondary relevance boost on top of BM25 to prioritize exact matches. Relevance scoring now: - BM25 base score (from FTS5) - +100 for exact phrase match in verse text - +50 per exact word match (e.g., 'kindness' exactly) - +10 per partial/stemmed match (e.g., 'kind' via stemming) Example: Searching for 'kindness' - Verses with 'kindness': BM25 + 150 (phrase + word) - Verses with 'kind': BM25 + 10 (partial match) This ensures exact matches appear first while still benefiting from Porter stemming to find all word variations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- backend/src/searchDatabase.js | 63 +++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/backend/src/searchDatabase.js b/backend/src/searchDatabase.js index e2bd63f1..43512586 100644 --- a/backend/src/searchDatabase.js +++ b/backend/src/searchDatabase.js @@ -199,17 +199,25 @@ class SearchDatabase { return reject(err); } - // Format results - const results = rows.map(row => ({ - book: row.book, - chapter: row.chapter, - verse: row.verse_number, - text: row.verse_text, - version: row.version, - highlight: row.highlighted_text, - relevance: -row.rank, // BM25 returns negative scores, negate for consistency - context: [] // Will be populated if requested - })); + // Format results with enhanced relevance scoring + const results = rows.map(row => { + const bm25Score = -row.rank; // BM25 returns negative scores + const exactMatchBoost = this.calculateExactMatchBoost(row.verse_text, query); + + return { + book: row.book, + chapter: row.chapter, + verse: row.verse_number, + text: row.verse_text, + version: row.version, + highlight: row.highlighted_text, + relevance: bm25Score + exactMatchBoost, + context: [] // Will be populated if requested + }; + }); + + // Re-sort by enhanced relevance (BM25 + exact match boost) + results.sort((a, b) => b.relevance - a.relevance); // Add context if requested if (includeContext && results.length > 0) { @@ -269,6 +277,39 @@ class SearchDatabase { return words.join(' AND '); } + // Calculate exact match boost for better relevance ranking + calculateExactMatchBoost(verseText, query) { + const lowerText = verseText.toLowerCase(); + const lowerQuery = query.toLowerCase().replace(/['"]/g, ''); // Remove quotes + let boost = 0; + + // Exact phrase match (highest boost) - e.g., "faith hope love" + if (lowerText.includes(lowerQuery)) { + boost += 100; + } + + // Exact word match boost - prioritize exact words over stemmed variants + const queryWords = lowerQuery.split(/\s+/).filter(w => w.length > 0); + const textWords = lowerText.split(/\W+/).filter(w => w.length > 0); + + for (const queryWord of queryWords) { + // Exact word match (e.g., "kindness" matches "kindness", not just "kind") + if (textWords.includes(queryWord)) { + boost += 50; + } else { + // Partial match (stemmed or substring) - lower boost + for (const textWord of textWords) { + if (textWord.includes(queryWord) || queryWord.includes(textWord)) { + boost += 10; + break; // Only count once per query word + } + } + } + } + + return boost; + } + // Get context verses around a target verse async getContext(book, chapter, verseNumber, version, contextSize = 2) { const start = Math.max(1, verseNumber - contextSize);