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);