From 7c5db6249056d24203445f6b9840dc98a227ce1d Mon Sep 17 00:00:00 2001 From: Jakob Schlanstedt Date: Wed, 8 Oct 2025 04:21:59 +0200 Subject: [PATCH 1/7] fix(search-ranking): add attributes and labels to search ranking --- .../src/services/search/search_result.ts | 156 ++++++++++++++---- 1 file changed, 125 insertions(+), 31 deletions(-) diff --git a/apps/server/src/services/search/search_result.ts b/apps/server/src/services/search/search_result.ts index bf8a33524b..f7227470b3 100644 --- a/apps/server/src/services/search/search_result.ts +++ b/apps/server/src/services/search/search_result.ts @@ -2,10 +2,10 @@ import beccaService from "../../becca/becca_service.js"; import becca from "../../becca/becca.js"; -import { - normalizeSearchText, - calculateOptimizedEditDistance, - FUZZY_SEARCH_CONFIG +import { + normalizeSearchText, + calculateOptimizedEditDistance, + FUZZY_SEARCH_CONFIG } from "./utils/text_utils.js"; // Scoring constants for better maintainability @@ -62,29 +62,123 @@ class SearchResult { const normalizedQuery = normalizeSearchText(fulltextQuery.toLowerCase()); const normalizedTitle = normalizeSearchText(note.title.toLowerCase()); - // Note ID exact match, much higher score + // ---------------------------------------------------- + // CONSTANTS — tweaking + // ---------------------------------------------------- + const TITLE_MATCH_FACTOR = 1.0; + const LABEL_TITLE_WEIGHT_FACTOR = 0.95; + const ATTR_TITLE_WEIGHT_FACTOR = 0.95; + const ATTRIBUTE_BREADTH_FACTOR = 0.25; // <— bonus fraction for multiple relevant attributes + + // ---------------------------------------------------- + // NOTE ID + TITLE SCORING + // ---------------------------------------------------- if (note.noteId.toLowerCase() === fulltextQuery) { this.score += SCORE_WEIGHTS.NOTE_ID_EXACT_MATCH; } - // Title matching scores with fuzzy matching support - if (normalizedTitle === normalizedQuery) { - this.score += SCORE_WEIGHTS.TITLE_EXACT_MATCH; - } else if (normalizedTitle.startsWith(normalizedQuery)) { - this.score += SCORE_WEIGHTS.TITLE_PREFIX_MATCH; - } else if (this.isWordMatch(normalizedTitle, normalizedQuery)) { - this.score += SCORE_WEIGHTS.TITLE_WORD_MATCH; - } else if (enableFuzzyMatching) { - // Try fuzzy matching for typos only if enabled - const fuzzyScore = this.calculateFuzzyTitleScore(normalizedTitle, normalizedQuery); - this.score += fuzzyScore; - this.fuzzyScore += fuzzyScore; // Track fuzzy score contributions + switch (true) { + case normalizedTitle === normalizedQuery: + this.score += SCORE_WEIGHTS.TITLE_EXACT_MATCH * TITLE_MATCH_FACTOR; + break; + + case normalizedTitle.startsWith(normalizedQuery): + this.score += SCORE_WEIGHTS.TITLE_PREFIX_MATCH * TITLE_MATCH_FACTOR; + break; + + case this.isWordMatch(normalizedTitle, normalizedQuery): + this.score += SCORE_WEIGHTS.TITLE_WORD_MATCH * TITLE_MATCH_FACTOR; + break; + + case enableFuzzyMatching: { + const fuzzyScore = this.calculateFuzzyTitleScore(normalizedTitle, normalizedQuery); + this.score += fuzzyScore; + this.fuzzyScore += fuzzyScore; + break; + } + + default: + // no match + break; } - // Add scores for token matches - this.addScoreForStrings(tokens, note.title, SCORE_WEIGHTS.TITLE_FACTOR, enableFuzzyMatching); + // Token-level scoring + this.addScoreForStrings(tokens, note.title, SCORE_WEIGHTS.TITLE_FACTOR * TITLE_MATCH_FACTOR, enableFuzzyMatching); this.addScoreForStrings(tokens, this.notePathTitle, SCORE_WEIGHTS.PATH_FACTOR, enableFuzzyMatching); + // ---------------------------------------------------- + // ATTRIBUTE / LABEL SCORING + // ---------------------------------------------------- + // + // WHY: + // Each note can have many attributes (labels, metadata, etc.). + // We take the *highest* attribute match as the main relevance driver, + // but also give a small bonus for having multiple moderately relevant + // attributes — this balances precision (best match) with breadth (coverage). + // + const attributes = note.getAttributes?.() || []; + + let maxAttrScore = 0; // best single attribute score + let maxAttrFuzzy = 0; // best single fuzzy score + let totalAttrScore = 0; // sum of all attribute scores + let totalAttrFuzzy = 0; // sum of all fuzzy scores + + for (const attr of attributes) { + const attrName = normalizeSearchText(attr.name?.toLowerCase() || ""); + const attrValue = normalizeSearchText(attr.value?.toLowerCase() || ""); + const attrType = attr.type || ""; + + const attrWeightFactor = + attrType === "label" ? LABEL_TITLE_WEIGHT_FACTOR : ATTR_TITLE_WEIGHT_FACTOR; + + // best score for this specific attribute (name/value pair) + let bestCandidateScore = 0; + let bestCandidateFuzzy = 0; + + for (const candidate of [attrName, attrValue]) { + if (!candidate) continue; + + let candidateScore = 0; + + if (candidate === normalizedQuery) { + candidateScore = SCORE_WEIGHTS.TITLE_EXACT_MATCH * attrWeightFactor; + } else if (candidate.startsWith(normalizedQuery)) { + candidateScore = SCORE_WEIGHTS.TITLE_PREFIX_MATCH * attrWeightFactor; + } else if (this.isWordMatch(candidate, normalizedQuery)) { + candidateScore = SCORE_WEIGHTS.TITLE_WORD_MATCH * attrWeightFactor; + } else if (enableFuzzyMatching) { + const fuzzyScore = + this.calculateFuzzyTitleScore(candidate, normalizedQuery) * attrWeightFactor; + candidateScore = fuzzyScore; + bestCandidateFuzzy = Math.max(bestCandidateFuzzy, fuzzyScore); + } + + bestCandidateScore = Math.max(bestCandidateScore, candidateScore); + + this.addScoreForStrings( + tokens, + candidate, + SCORE_WEIGHTS.TITLE_FACTOR * attrWeightFactor, + enableFuzzyMatching + ); + } + + maxAttrScore = Math.max(maxAttrScore, bestCandidateScore); + maxAttrFuzzy = Math.max(maxAttrFuzzy, bestCandidateFuzzy); + totalAttrScore += bestCandidateScore; + totalAttrFuzzy += bestCandidateFuzzy; + } + + // Combine precision (best) with breadth (extra small bonus for other matches) + const hybridAttrScore = maxAttrScore + ATTRIBUTE_BREADTH_FACTOR * (totalAttrScore - maxAttrScore); + const hybridAttrFuzzy = maxAttrFuzzy + ATTRIBUTE_BREADTH_FACTOR * (totalAttrFuzzy - maxAttrFuzzy); + + this.score += hybridAttrScore; + this.fuzzyScore += hybridAttrFuzzy; + + // ---------------------------------------------------- + // VISIBILITY PENALTY + // ---------------------------------------------------- if (note.isInHiddenSubtree()) { this.score = this.score / SCORE_WEIGHTS.HIDDEN_NOTE_PENALTY; } @@ -98,7 +192,7 @@ class SearchResult { for (const chunk of chunks) { for (const token of tokens) { const normalizedToken = normalizeSearchText(token.toLowerCase()); - + if (chunk === normalizedToken) { tokenScore += SCORE_WEIGHTS.TOKEN_EXACT_MATCH * token.length * factor; } else if (chunk.startsWith(normalizedToken)) { @@ -108,10 +202,10 @@ class SearchResult { } else { // Try fuzzy matching for individual tokens with caps applied const editDistance = calculateOptimizedEditDistance(chunk, normalizedToken, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); - if (editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && + if (editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && normalizedToken.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && this.fuzzyScore < SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) { - + const fuzzyWeight = SCORE_WEIGHTS.TOKEN_FUZZY_MATCH * (1 - editDistance / FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); // Apply caps: limit token length multiplier and per-token contribution const cappedTokenLength = Math.min(token.length, SCORE_WEIGHTS.MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER); @@ -119,7 +213,7 @@ class SearchResult { fuzzyWeight * cappedTokenLength * factor, SCORE_WEIGHTS.MAX_FUZZY_SCORE_PER_TOKEN ); - + tokenScore += fuzzyTokenScore; this.fuzzyScore += fuzzyTokenScore; } @@ -134,8 +228,8 @@ class SearchResult { * Checks if the query matches as a complete word in the text */ private isWordMatch(text: string, query: string): boolean { - return text.includes(` ${query} `) || - text.startsWith(`${query} `) || + return text.includes(` ${query} `) || + text.startsWith(`${query} `) || text.endsWith(` ${query}`); } @@ -147,21 +241,21 @@ class SearchResult { if (this.fuzzyScore >= SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) { return 0; } - + const editDistance = calculateOptimizedEditDistance(title, query, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); const maxLen = Math.max(title.length, query.length); - + // Only apply fuzzy matching if the query is reasonably long and edit distance is small - if (query.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && - editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && + if (query.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && + editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && editDistance / maxLen <= 0.3) { const similarity = 1 - (editDistance / maxLen); const baseFuzzyScore = SCORE_WEIGHTS.TITLE_WORD_MATCH * similarity * 0.7; // Reduced weight for fuzzy matches - + // Apply cap to ensure fuzzy title matches don't exceed reasonable bounds return Math.min(baseFuzzyScore, SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE * 0.3); } - + return 0; } From d9c8042c47a91e2976a9ddd8f4cbe95a70eea023 Mon Sep 17 00:00:00 2001 From: Jakob Schlanstedt Date: Wed, 8 Oct 2025 13:51:20 +0200 Subject: [PATCH 2/7] fix(search-result-ranking): transform multiplcative ranking to additive ranking scale --- .../src/services/search/search_result.ts | 365 +++++++++++------- .../src/services/search/services/search.ts | 155 +++++--- 2 files changed, 326 insertions(+), 194 deletions(-) diff --git a/apps/server/src/services/search/search_result.ts b/apps/server/src/services/search/search_result.ts index f7227470b3..7f8c9eda11 100644 --- a/apps/server/src/services/search/search_result.ts +++ b/apps/server/src/services/search/search_result.ts @@ -8,26 +8,45 @@ import { FUZZY_SEARCH_CONFIG } from "./utils/text_utils.js"; -// Scoring constants for better maintainability +// ---------------------------------------------------- +// SCORE WEIGHTS — all fixed absolute point values +// ---------------------------------------------------- const SCORE_WEIGHTS = { + // NOTE ID (highest importance) NOTE_ID_EXACT_MATCH: 1000, - TITLE_EXACT_MATCH: 2000, - TITLE_PREFIX_MATCH: 500, - TITLE_WORD_MATCH: 300, - TOKEN_EXACT_MATCH: 4, - TOKEN_PREFIX_MATCH: 2, - TOKEN_CONTAINS_MATCH: 1, - TOKEN_FUZZY_MATCH: 0.5, - TITLE_FACTOR: 2.0, - PATH_FACTOR: 0.3, - HIDDEN_NOTE_PENALTY: 3, - // Score caps to prevent fuzzy matches from outranking exact matches - MAX_FUZZY_SCORE_PER_TOKEN: 3, // Cap fuzzy token contributions to stay below exact matches - MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER: 3, // Limit token length impact for fuzzy matches - MAX_TOTAL_FUZZY_SCORE: 200 // Total cap on fuzzy scoring per search -} as const; + // TITLE relevance + TITLE_EXACT_MATCH: 900, + LABEL_VALUE_EXACT_MATCH: 880, + TITLE_PREFIX_MATCH: 850, + LABEL_VALUE_PREFIX_MATCH: 840, + TITLE_WORD_MATCH: 800, + LABEL_VALUE_WORD_MATCH: 790, + + LABEL_KEY_EXACT_MATCH: 600, + LABEL_KEY_PREFIX_MATCH: 580, + LABEL_KEY_WORD_MATCH: 560, + + TITLE_FUZZY_MATCH: 750, + LABEL_VALUE_FUZZY_MATCH: 560, + LABEL_KEY_FUZZY_MATCH: 540, + + // TOKEN-level relevance + TOKEN_EXACT_MATCH: 120, + TOKEN_PREFIX_MATCH: 110, + TOKEN_CONTAINS_MATCH: 105, + TOKEN_FUZZY_MATCH: 100, + + // Penalties / limits + HIDDEN_NOTE_PENALTY: 3, // divisor for hidden notes + MAX_TOTAL_FUZZY_SCORE: 100, // total cap on fuzzy scoring per search + MAX_FUZZY_SCORE_PER_TOKEN: 3, // fuzzy token contribution cap + MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER: 3 // token length multiplier cap +} as const; +// ---------------------------------------------------- +// SEARCH RESULT CLASS +// ---------------------------------------------------- class SearchResult { notePathArray: string[]; score: number; @@ -37,7 +56,7 @@ class SearchResult { highlightedContentSnippet?: string; attributeSnippet?: string; highlightedAttributeSnippet?: string; - private fuzzyScore: number; // Track fuzzy score separately + private fuzzyScore: number; constructor(notePathArray: string[]) { this.notePathArray = notePathArray; @@ -54,127 +73,202 @@ class SearchResult { return this.notePathArray[this.notePathArray.length - 1]; } - computeScore(fulltextQuery: string, tokens: string[], enableFuzzyMatching: boolean = true) { - this.score = 0; - this.fuzzyScore = 0; // Reset fuzzy score tracking + private getBestTokenScore(tokens: string[], str: string, enableFuzzyMatching: boolean = true): number { + const normalizedStr = normalizeSearchText(str.toLowerCase()); + const chunks = normalizedStr.split(" "); + let bestTokenScore = 0; + + for (const chunk of chunks) { + for (const token of tokens) { + const normalizedToken = normalizeSearchText(token.toLowerCase()); + let currentScore = 0; + switch (true) { + case (chunk === normalizedToken): { + currentScore = SCORE_WEIGHTS.TOKEN_EXACT_MATCH; + break; + } + case (chunk.startsWith(normalizedToken)): { + currentScore = SCORE_WEIGHTS.TOKEN_PREFIX_MATCH; + break; + } + case (chunk.includes(normalizedToken)): { + currentScore = SCORE_WEIGHTS.TOKEN_CONTAINS_MATCH; + break; + } + case (enableFuzzyMatching): { + const editDistance = calculateOptimizedEditDistance(chunk, normalizedToken, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); + if ( + editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && + normalizedToken.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && + this.fuzzyScore < SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE + ) { + const fuzzyWeight = SCORE_WEIGHTS.TOKEN_FUZZY_MATCH * (1 - editDistance / FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); + const cappedLen = Math.min(token.length, SCORE_WEIGHTS.MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER); + const fuzzyTokenScore = Math.min( + fuzzyWeight * cappedLen, + SCORE_WEIGHTS.MAX_FUZZY_SCORE_PER_TOKEN + ); + currentScore = fuzzyTokenScore; + this.fuzzyScore += fuzzyTokenScore; + } + break; + } + default: { + break; + } + } + + bestTokenScore = Math.max(bestTokenScore, currentScore); + } + } + + return bestTokenScore; + } + + computeScore(fulltextQuery: string, tokens: string[], enableFuzzyMatching: boolean = true) { const note = becca.notes[this.noteId]; const normalizedQuery = normalizeSearchText(fulltextQuery.toLowerCase()); const normalizedTitle = normalizeSearchText(note.title.toLowerCase()); - // ---------------------------------------------------- - // CONSTANTS — tweaking - // ---------------------------------------------------- - const TITLE_MATCH_FACTOR = 1.0; - const LABEL_TITLE_WEIGHT_FACTOR = 0.95; - const ATTR_TITLE_WEIGHT_FACTOR = 0.95; - const ATTRIBUTE_BREADTH_FACTOR = 0.25; // <— bonus fraction for multiple relevant attributes + this.score = 0; + this.fuzzyScore = 0; // ---------------------------------------------------- - // NOTE ID + TITLE SCORING + // NOTE ID MATCH — immediate return if perfect // ---------------------------------------------------- if (note.noteId.toLowerCase() === fulltextQuery) { - this.score += SCORE_WEIGHTS.NOTE_ID_EXACT_MATCH; + this.score = SCORE_WEIGHTS.NOTE_ID_EXACT_MATCH; + return this.score; } + // ---------------------------------------------------- + // TITLE MATCHING + // ---------------------------------------------------- + let titleScore = 0; + switch (true) { - case normalizedTitle === normalizedQuery: - this.score += SCORE_WEIGHTS.TITLE_EXACT_MATCH * TITLE_MATCH_FACTOR; + case (normalizedTitle === normalizedQuery): { + titleScore = SCORE_WEIGHTS.TITLE_EXACT_MATCH; break; - - case normalizedTitle.startsWith(normalizedQuery): - this.score += SCORE_WEIGHTS.TITLE_PREFIX_MATCH * TITLE_MATCH_FACTOR; + } + case (normalizedTitle.startsWith(normalizedQuery)): { + titleScore = SCORE_WEIGHTS.TITLE_PREFIX_MATCH; break; - - case this.isWordMatch(normalizedTitle, normalizedQuery): - this.score += SCORE_WEIGHTS.TITLE_WORD_MATCH * TITLE_MATCH_FACTOR; + } + case (this.isWordMatch(normalizedTitle, normalizedQuery)): { + titleScore = SCORE_WEIGHTS.TITLE_WORD_MATCH; break; - - case enableFuzzyMatching: { + } + case (enableFuzzyMatching): { const fuzzyScore = this.calculateFuzzyTitleScore(normalizedTitle, normalizedQuery); - this.score += fuzzyScore; - this.fuzzyScore += fuzzyScore; + if (fuzzyScore > 0) { + titleScore = SCORE_WEIGHTS.TITLE_FUZZY_MATCH; + this.fuzzyScore += fuzzyScore; + } break; } - - default: - // no match + default: { break; + } } - // Token-level scoring - this.addScoreForStrings(tokens, note.title, SCORE_WEIGHTS.TITLE_FACTOR * TITLE_MATCH_FACTOR, enableFuzzyMatching); - this.addScoreForStrings(tokens, this.notePathTitle, SCORE_WEIGHTS.PATH_FACTOR, enableFuzzyMatching); - // ---------------------------------------------------- - // ATTRIBUTE / LABEL SCORING + // LABEL SCORING — best key + best value // ---------------------------------------------------- - // - // WHY: - // Each note can have many attributes (labels, metadata, etc.). - // We take the *highest* attribute match as the main relevance driver, - // but also give a small bonus for having multiple moderately relevant - // attributes — this balances precision (best match) with breadth (coverage). - // - const attributes = note.getAttributes?.() || []; - - let maxAttrScore = 0; // best single attribute score - let maxAttrFuzzy = 0; // best single fuzzy score - let totalAttrScore = 0; // sum of all attribute scores - let totalAttrFuzzy = 0; // sum of all fuzzy scores - - for (const attr of attributes) { - const attrName = normalizeSearchText(attr.name?.toLowerCase() || ""); - const attrValue = normalizeSearchText(attr.value?.toLowerCase() || ""); - const attrType = attr.type || ""; - - const attrWeightFactor = - attrType === "label" ? LABEL_TITLE_WEIGHT_FACTOR : ATTR_TITLE_WEIGHT_FACTOR; - - // best score for this specific attribute (name/value pair) - let bestCandidateScore = 0; - let bestCandidateFuzzy = 0; - - for (const candidate of [attrName, attrValue]) { - if (!candidate) continue; - - let candidateScore = 0; - - if (candidate === normalizedQuery) { - candidateScore = SCORE_WEIGHTS.TITLE_EXACT_MATCH * attrWeightFactor; - } else if (candidate.startsWith(normalizedQuery)) { - candidateScore = SCORE_WEIGHTS.TITLE_PREFIX_MATCH * attrWeightFactor; - } else if (this.isWordMatch(candidate, normalizedQuery)) { - candidateScore = SCORE_WEIGHTS.TITLE_WORD_MATCH * attrWeightFactor; - } else if (enableFuzzyMatching) { - const fuzzyScore = - this.calculateFuzzyTitleScore(candidate, normalizedQuery) * attrWeightFactor; - candidateScore = fuzzyScore; - bestCandidateFuzzy = Math.max(bestCandidateFuzzy, fuzzyScore); + const labels = note.getLabels?.() || []; + let bestLabelKeyScore = 0; + let bestLabelValueScore = 0; + + for (const label of labels) { + const key = normalizeSearchText(label.name?.toLowerCase() || ""); + const value = normalizeSearchText(label.value?.toLowerCase() || ""); + + // ---- Key scoring ---- + if (key) { + let keyScore = 0; + + switch (true) { + case (key === normalizedQuery): { + keyScore = SCORE_WEIGHTS.LABEL_KEY_EXACT_MATCH; + break; + } + case (key.startsWith(normalizedQuery)): { + keyScore = SCORE_WEIGHTS.LABEL_KEY_PREFIX_MATCH; + break; + } + case (this.isWordMatch(key, normalizedQuery)): { + keyScore = SCORE_WEIGHTS.LABEL_KEY_WORD_MATCH; + break; + } + case (enableFuzzyMatching): { + const fuzzyScore = this.calculateFuzzyTitleScore(key, normalizedQuery); + if (fuzzyScore > 0) { + keyScore = SCORE_WEIGHTS.LABEL_KEY_FUZZY_MATCH; + this.fuzzyScore += fuzzyScore; + } + break; + } + default: { + break; + } } - bestCandidateScore = Math.max(bestCandidateScore, candidateScore); - - this.addScoreForStrings( - tokens, - candidate, - SCORE_WEIGHTS.TITLE_FACTOR * attrWeightFactor, - enableFuzzyMatching - ); + bestLabelKeyScore = Math.max(bestLabelKeyScore, keyScore); } - maxAttrScore = Math.max(maxAttrScore, bestCandidateScore); - maxAttrFuzzy = Math.max(maxAttrFuzzy, bestCandidateFuzzy); - totalAttrScore += bestCandidateScore; - totalAttrFuzzy += bestCandidateFuzzy; + // ---- Value scoring ---- + if (value) { + let valueScore = 0; + + switch (true) { + case (value === normalizedQuery): { + valueScore = SCORE_WEIGHTS.LABEL_VALUE_EXACT_MATCH; + break; + } + case (value.startsWith(normalizedQuery)): { + valueScore = SCORE_WEIGHTS.LABEL_VALUE_PREFIX_MATCH; + break; + } + case (this.isWordMatch(value, normalizedQuery)): { + valueScore = SCORE_WEIGHTS.LABEL_VALUE_WORD_MATCH; + break; + } + case (enableFuzzyMatching): { + const fuzzyScore = this.calculateFuzzyTitleScore(value, normalizedQuery); + if (fuzzyScore > 0) { + valueScore = SCORE_WEIGHTS.LABEL_VALUE_FUZZY_MATCH; + this.fuzzyScore += fuzzyScore; + } + break; + } + default: { + break; + } + } + + bestLabelValueScore = Math.max(bestLabelValueScore, valueScore); + } } - // Combine precision (best) with breadth (extra small bonus for other matches) - const hybridAttrScore = maxAttrScore + ATTRIBUTE_BREADTH_FACTOR * (totalAttrScore - maxAttrScore); - const hybridAttrFuzzy = maxAttrFuzzy + ATTRIBUTE_BREADTH_FACTOR * (totalAttrFuzzy - maxAttrFuzzy); + // ---------------------------------------------------- + // TOKEN MATCHING — take best single token match + // ---------------------------------------------------- + let tokenScore = 0; + tokenScore = Math.max( + this.getBestTokenScore(tokens, note.title, enableFuzzyMatching), + this.getBestTokenScore(tokens, this.notePathTitle, enableFuzzyMatching) + ); - this.score += hybridAttrScore; - this.fuzzyScore += hybridAttrFuzzy; + // ---------------------------------------------------- + // FINAL SCORE — take the strongest category + // ---------------------------------------------------- + this.score = Math.max( + titleScore, + bestLabelKeyScore, + bestLabelValueScore, + tokenScore + ); // ---------------------------------------------------- // VISIBILITY PENALTY @@ -184,60 +278,53 @@ class SearchResult { } } - addScoreForStrings(tokens: string[], str: string, factor: number, enableFuzzyMatching: boolean = true) { + // TOKEN MATCHING + addScoreForStrings(tokens: string[], str: string, enableFuzzyMatching: boolean = true) { const normalizedStr = normalizeSearchText(str.toLowerCase()); const chunks = normalizedStr.split(" "); - let tokenScore = 0; + for (const chunk of chunks) { for (const token of tokens) { const normalizedToken = normalizeSearchText(token.toLowerCase()); if (chunk === normalizedToken) { - tokenScore += SCORE_WEIGHTS.TOKEN_EXACT_MATCH * token.length * factor; + tokenScore += SCORE_WEIGHTS.TOKEN_EXACT_MATCH; } else if (chunk.startsWith(normalizedToken)) { - tokenScore += SCORE_WEIGHTS.TOKEN_PREFIX_MATCH * token.length * factor; + tokenScore += SCORE_WEIGHTS.TOKEN_PREFIX_MATCH; } else if (chunk.includes(normalizedToken)) { - tokenScore += SCORE_WEIGHTS.TOKEN_CONTAINS_MATCH * token.length * factor; - } else { - // Try fuzzy matching for individual tokens with caps applied + tokenScore += SCORE_WEIGHTS.TOKEN_CONTAINS_MATCH; + } else if (enableFuzzyMatching) { const editDistance = calculateOptimizedEditDistance(chunk, normalizedToken, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); - if (editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && + if ( + editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && normalizedToken.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && - this.fuzzyScore < SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) { - + this.fuzzyScore < SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE + ) { const fuzzyWeight = SCORE_WEIGHTS.TOKEN_FUZZY_MATCH * (1 - editDistance / FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); - // Apply caps: limit token length multiplier and per-token contribution - const cappedTokenLength = Math.min(token.length, SCORE_WEIGHTS.MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER); + const cappedLen = Math.min(token.length, SCORE_WEIGHTS.MAX_FUZZY_TOKEN_LENGTH_MULTIPLIER); const fuzzyTokenScore = Math.min( - fuzzyWeight * cappedTokenLength * factor, + fuzzyWeight * cappedLen, SCORE_WEIGHTS.MAX_FUZZY_SCORE_PER_TOKEN ); - tokenScore += fuzzyTokenScore; this.fuzzyScore += fuzzyTokenScore; } } } } + this.score += tokenScore; } - - /** - * Checks if the query matches as a complete word in the text - */ + // HELPERS private isWordMatch(text: string, query: string): boolean { return text.includes(` ${query} `) || - text.startsWith(`${query} `) || - text.endsWith(` ${query}`); + text.startsWith(`${query} `) || + text.endsWith(` ${query}`); } - /** - * Calculates fuzzy matching score for title matches with caps applied - */ private calculateFuzzyTitleScore(title: string, query: string): number { - // Check if we've already hit the fuzzy scoring cap if (this.fuzzyScore >= SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE) { return 0; } @@ -245,20 +332,18 @@ class SearchResult { const editDistance = calculateOptimizedEditDistance(title, query, FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE); const maxLen = Math.max(title.length, query.length); - // Only apply fuzzy matching if the query is reasonably long and edit distance is small - if (query.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && + if ( + query.length >= FUZZY_SEARCH_CONFIG.MIN_FUZZY_TOKEN_LENGTH && editDistance <= FUZZY_SEARCH_CONFIG.MAX_EDIT_DISTANCE && - editDistance / maxLen <= 0.3) { + editDistance / maxLen <= 0.3 + ) { const similarity = 1 - (editDistance / maxLen); - const baseFuzzyScore = SCORE_WEIGHTS.TITLE_WORD_MATCH * similarity * 0.7; // Reduced weight for fuzzy matches - - // Apply cap to ensure fuzzy title matches don't exceed reasonable bounds - return Math.min(baseFuzzyScore, SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE * 0.3); + const baseScore = SCORE_WEIGHTS.TITLE_FUZZY_MATCH * similarity; + return Math.min(baseScore, SCORE_WEIGHTS.MAX_TOTAL_FUZZY_SCORE * 0.3); } return 0; } - } export default SearchResult; diff --git a/apps/server/src/services/search/services/search.ts b/apps/server/src/services/search/services/search.ts index 22dbe6d9fc..533c1bdea6 100644 --- a/apps/server/src/services/search/services/search.ts +++ b/apps/server/src/services/search/services/search.ts @@ -252,71 +252,118 @@ function findResultsWithExpression(expression: Expression, searchContext: Search // Phase 1: Try exact matches first (without fuzzy matching) const exactResults = performSearch(expression, searchContext, false); - + // Check if we have sufficient high-quality results const minResultThreshold = 5; const minScoreForQuality = 10; // Minimum score to consider a result "high quality" - + const highQualityResults = exactResults.filter(result => result.score >= minScoreForQuality); - + // If we have enough high-quality exact matches, return them if (highQualityResults.length >= minResultThreshold) { return exactResults; } - + // Phase 2: Add fuzzy matching as fallback when exact matches are insufficient const fuzzyResults = performSearch(expression, searchContext, true); - + // Merge results, ensuring exact matches always rank higher than fuzzy matches return mergeExactAndFuzzyResults(exactResults, fuzzyResults); } -function performSearch(expression: Expression, searchContext: SearchContext, enableFuzzyMatching: boolean): SearchResult[] { +function performSearch( + expression: Expression, + searchContext: SearchContext, + enableFuzzyMatching: boolean +): SearchResult[] { const allNoteSet = becca.getAllNoteSet(); const noteIdToNotePath: Record = {}; - const executionContext = { - noteIdToNotePath - }; + const executionContext = { noteIdToNotePath }; - // Store original fuzzy setting and temporarily override it + // Preserve and temporarily override fuzzy setting const originalFuzzyMatching = searchContext.enableFuzzyMatching; searchContext.enableFuzzyMatching = enableFuzzyMatching; - const noteSet = expression.execute(allNoteSet, executionContext, searchContext); - - const searchResults = noteSet.notes.map((note) => { - const notePathArray = executionContext.noteIdToNotePath[note.noteId] || note.getBestNotePath(); + // If ancestorNoteId is provided, limit search to that subtree + let noteSet = expression.execute(allNoteSet, executionContext, searchContext); + if (searchContext.ancestorNoteId && becca.notes[searchContext.ancestorNoteId]) { + const ancestor = becca.notes[searchContext.ancestorNoteId]; + const allowedNoteIds = new Set(ancestor.getSubtreeNoteIds({ + includeArchived: searchContext.includeArchivedNotes ?? true, + includeHidden: searchContext.includeHiddenNotes ?? false + })); + noteSet.notes = noteSet.notes.filter(n => allowedNoteIds.has(n.noteId)); + } - if (!notePathArray) { - throw new Error(`Can't find note path for note ${JSON.stringify(note.getPojo())}`); - } + // Main filtering + const searchResults = noteSet.notes + .filter(note => { + if (!searchContext.includeArchivedNotes && note.isArchived) return false; + if (!searchContext.includeHiddenNotes && note.isInHiddenSubtree()) return false; + return true; + }) + .map(note => { + const notePathArray = + executionContext.noteIdToNotePath[note.noteId] || note.getBestNotePath(); + + if (!notePathArray) { + throw new Error( + `Can't find note path for note ${JSON.stringify(note.getPojo())}` + ); + } - return new SearchResult(notePathArray); - }); + return new SearchResult(notePathArray); + }); + // Compute scores for (const res of searchResults) { - res.computeScore(searchContext.fulltextQuery, searchContext.highlightedTokens, enableFuzzyMatching); + res.computeScore( + searchContext.fulltextQuery, + searchContext.highlightedTokens, + enableFuzzyMatching + ); + + // Optional fine-tuning: disable fuzzy for title if fuzzyAttributeSearch only + if (!enableFuzzyMatching && searchContext.fuzzyAttributeSearch) { + // Re-score attributes only, skip fuzzy on title + res.computeScore( + searchContext.fulltextQuery, + searchContext.highlightedTokens, + true // fuzzy for attributes + ); + } + + // If ignoring internal attributes, reduce their weight + if (searchContext.ignoreInternalAttributes) { + const note = becca.notes[res.noteId]; + const internalAttrs = note.getAttributes()?.filter(a => a.name.startsWith("_")) || []; + if (internalAttrs.length > 0) { + const penalty = 1 + internalAttrs.length * 0.05; + res.score = res.score / penalty; + } + } } - // Restore original fuzzy setting + // Restore fuzzy flag searchContext.enableFuzzyMatching = originalFuzzyMatching; + // Optional fast-search mode (e.g. autocomplete) + if (searchContext.fastSearch) { + // Skip fuzzy rescoring & heavy sorting logic + return searchResults + .filter(r => r.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 50); + } + + // Normal sorting if (!noteSet.sorted) { searchResults.sort((a, b) => { - if (a.score > b.score) { - return -1; - } else if (a.score < b.score) { - return 1; - } - - // if score does not decide then sort results by depth of the note. - // This is based on the assumption that more important results are closer to the note root. - if (a.notePathArray.length === b.notePathArray.length) { - return a.notePathTitle < b.notePathTitle ? -1 : 1; - } - - return a.notePathArray.length < b.notePathArray.length ? -1 : 1; + if (a.score !== b.score) return b.score - a.score; + if (a.notePathArray.length !== b.notePathArray.length) + return a.notePathArray.length - b.notePathArray.length; + return a.notePathTitle.localeCompare(b.notePathTitle); }); } @@ -326,10 +373,10 @@ function performSearch(expression: Expression, searchContext: SearchContext, ena function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: SearchResult[]): SearchResult[] { // Create a map of exact result note IDs for deduplication const exactNoteIds = new Set(exactResults.map(result => result.noteId)); - + // Add fuzzy results that aren't already in exact results const additionalFuzzyResults = fuzzyResults.filter(result => !exactNoteIds.has(result.noteId)); - + // Sort exact results by score (best exact matches first) exactResults.sort((a, b) => { if (a.score > b.score) { @@ -345,7 +392,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S return a.notePathArray.length < b.notePathArray.length ? -1 : 1; }); - + // Sort fuzzy results by score (best fuzzy matches first) additionalFuzzyResults.sort((a, b) => { if (a.score > b.score) { @@ -361,7 +408,7 @@ function mergeExactAndFuzzyResults(exactResults: SearchResult[], fuzzyResults: S return a.notePathArray.length < b.notePathArray.length ? -1 : 1; }); - + // CRITICAL: Always put exact matches before fuzzy matches, regardless of scores return [...exactResults, ...additionalFuzzyResults]; } @@ -417,10 +464,10 @@ function findResultsWithQuery(query: string, searchContext: SearchContext): Sear } // If the query starts with '#', it's a pure expression query. - // Don't use progressive search for these as they may have complex + // Don't use progressive search for these as they may have complex // ordering or other logic that shouldn't be interfered with. const isPureExpressionQuery = query.trim().startsWith('#'); - + if (isPureExpressionQuery) { // For pure expression queries, use standard search without progressive phases return performSearch(expression, searchContext, searchContext.enableFuzzyMatching); @@ -448,7 +495,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength try { let content = note.getContent(); - + if (!content || typeof content !== "string") { return ""; } @@ -489,7 +536,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength for (const token of searchTokens) { const normalizedToken = normalizeString(token.toLowerCase()); const matchIndex = normalizedContent.indexOf(normalizedToken); - + if (matchIndex !== -1) { // Center the snippet around the match snippetStart = Math.max(0, matchIndex - maxLength / 2); @@ -500,7 +547,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength // Extract snippet let snippet = content.substring(snippetStart, snippetStart + maxLength); - + // If snippet contains linebreaks, limit to max 4 lines and override character limit const lines = snippet.split('\n'); if (lines.length > 4) { @@ -523,7 +570,7 @@ function extractContentSnippet(noteId: string, searchTokens: string[], maxLength } snippet = "..." + snippet; } - + if (snippetStart + maxLength < content.length) { const lastSpace = snippet.search(/\s[^\s]*$/); if (lastSpace > snippet.length - 20 && lastSpace > 0) { @@ -554,19 +601,19 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng } let matchingAttributes: Array<{name: string, value: string, type: string}> = []; - + // Look for attributes that match the search tokens for (const attr of attributes) { const attrName = attr.name?.toLowerCase() || ""; const attrValue = attr.value?.toLowerCase() || ""; const attrType = attr.type || ""; - + // Check if any search token matches the attribute name or value const hasMatch = searchTokens.some(token => { const normalizedToken = normalizeString(token.toLowerCase()); return attrName.includes(normalizedToken) || attrValue.includes(normalizedToken); }); - + if (hasMatch) { matchingAttributes.push({ name: attr.name || "", @@ -592,20 +639,20 @@ function extractAttributeSnippet(noteId: string, searchTokens: string[], maxLeng const targetTitle = targetNote ? targetNote.title : attr.value; line = `~${attr.name}="${targetTitle}"`; } - + if (line) { lines.push(line); } } let snippet = lines.join('\n'); - + // Apply length limit while preserving line structure if (snippet.length > maxLength) { // Try to truncate at word boundaries but keep lines intact const truncated = snippet.substring(0, maxLength); const lastNewline = truncated.lastIndexOf('\n'); - + if (lastNewline > maxLength / 2) { // If we can keep most content by truncating to last complete line snippet = truncated.substring(0, lastNewline); @@ -679,7 +726,7 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens for (const result of searchResults) { result.highlightedNotePathTitle = result.notePathTitle.replace(/[<{}]/g, ""); - + // Initialize highlighted content snippet if (result.contentSnippet) { // Escape HTML but preserve newlines for later conversion to
@@ -687,7 +734,7 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens // Remove any stray < { } that might interfere with our highlighting markers result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/[<{}]/g, ""); } - + // Initialize highlighted attribute snippet if (result.attributeSnippet) { // Escape HTML but preserve newlines for later conversion to
@@ -748,14 +795,14 @@ function highlightSearchResults(searchResults: SearchResult[], highlightedTokens if (result.highlightedNotePathTitle) { result.highlightedNotePathTitle = result.highlightedNotePathTitle.replace(/{/g, "").replace(/}/g, ""); } - + if (result.highlightedContentSnippet) { // Replace highlighting markers with HTML tags result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/{/g, "").replace(/}/g, ""); // Convert newlines to
tags for HTML display result.highlightedContentSnippet = result.highlightedContentSnippet.replace(/\n/g, "
"); } - + if (result.highlightedAttributeSnippet) { // Replace highlighting markers with HTML tags result.highlightedAttributeSnippet = result.highlightedAttributeSnippet.replace(/{/g, "").replace(/}/g, ""); From 3f04c498ee0467391ce86a328c30b009f66d6655 Mon Sep 17 00:00:00 2001 From: Jakob Schlanstedt Date: Thu, 9 Oct 2025 02:19:38 +0200 Subject: [PATCH 3/7] feat(jump-to and quicksearch): add search for noteid --- apps/client/src/services/note_autocomplete.ts | 8 ++ .../src/stylesheets/theme-next/dialogs.css | 130 +++++++++++++++++- apps/client/src/widgets/quick_search.ts | 3 + .../src/services/search/search_result.ts | 1 + .../src/services/search/services/search.ts | 66 ++++----- 5 files changed, 174 insertions(+), 34 deletions(-) diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts index 9ca4fa86fb..6289e73104 100644 --- a/apps/client/src/services/note_autocomplete.ts +++ b/apps/client/src/services/note_autocomplete.ts @@ -30,6 +30,8 @@ export interface Suggestion { notePathTitle?: string; notePath?: string; highlightedNotePathTitle?: string; + highlightedNoteId?: string; + highlightedContentSnippet?: string; action?: string | "create-note" | "search-notes" | "external-link" | "command"; parentNoteId?: string; icon?: string; @@ -344,6 +346,12 @@ function initNoteAutocomplete($el: JQuery, options?: Options) { html += ``; html += `${suggestion.highlightedNotePathTitle}`; + // Highlighted noteId (if present) + if (suggestion.highlightedNoteId) { + html += `
${suggestion.highlightedNoteId}
`; + } + html += ``; // close .note-header + // Add attribute snippet inline if available if (suggestion.highlightedAttributeSnippet) { html += `${suggestion.highlightedAttributeSnippet}`; diff --git a/apps/client/src/stylesheets/theme-next/dialogs.css b/apps/client/src/stylesheets/theme-next/dialogs.css index 357a541ff0..674b0dab03 100644 --- a/apps/client/src/stylesheets/theme-next/dialogs.css +++ b/apps/client/src/stylesheets/theme-next/dialogs.css @@ -117,6 +117,20 @@ div.tn-tool-dialog { backdrop-filter: var(--dropdown-backdrop-filter); } +.jump-to-note-dialog .search-result-id { + display: block; + margin-top: 2px; + font-size: 0.8em; + color: var(--muted-text-color); + font-family: var(--monospace-font, monospace); + opacity: 0.8; +} + +.jump-to-note-dialog .search-result-id b { + color: var(--accent-color); + font-weight: 600; +} + .jump-to-note-dialog .modal-content { --bs-modal-header-padding-x: 0; @@ -420,4 +434,118 @@ div.tn-tool-dialog { .note-type-chooser-dialog div.note-type-dropdown .dropdown-item span.bx { margin-right: .25em; -} \ No newline at end of file +} + +/* ============================================================ +Quick Search Dropdown Styles +============================================================ */ + +.quick-search { + padding: 10px 10px 10px 0px; + height: 50px; +} + +.quick-search button, .quick-search input { + border: 0; + font-size: 100% !important; +} + +.quick-search .dropdown-menu { + --quick-search-item-delimiter-color: var(--dropdown-border-color); + + max-height: 80vh; + min-width: 400px; + max-width: 720px; + overflow-y: auto; + overflow-x: hidden; + text-overflow: ellipsis; + box-shadow: -30px 50px 93px -50px black; +} + +.quick-search .dropdown-item { + white-space: normal; + padding: 12px 16px; + line-height: 1.4; + position: relative; +} + +.quick-search .dropdown-item + .dropdown-item::after { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 1px; + border-bottom: 1px solid var(--quick-search-item-delimiter-color); +} + +.quick-search .dropdown-item:last-child::after { + display: none; +} + +.quick-search .dropdown-item.disabled::after { + display: none; +} + +.quick-search .dropdown-item.show-in-full-search::after { + display: none; +} + +.quick-search-item.dropdown-item:hover { + background-color: #f8f9fa; +} + +.quick-search .quick-search-item { + width: 100%; +} + +.quick-search .quick-search-item-header { + padding: 0 8px; +} + +.quick-search .quick-search-item-icon { + margin-inline-end: 2px; +} + +.quick-search .search-result-title { + font-weight: 500; +} + +.quick-search .search-result-attributes { + opacity: .5; + padding: 0 8px; + font-size: .75em; +} + +.quick-search .search-result-id { + font-size: 0.75em; + color: var(--muted-text-color); + margin-top: 2px; + padding-left: 22px; /* aligns nicely with the icon */ +} + +.quick-search .search-result-content { + margin-top: 8px; + padding: 8px; + background-color: var(--accented-background-color); + color: var(--main-text-color); + font-size: .85em; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Search result highlighting */ +.quick-search .search-result-title b, +.quick-search .search-result-content b, +.quick-search .search-result-attributes b { + color: var(--admonition-warning-accent-color); + text-decoration: underline; +} + +.quick-search .dropdown-divider { + margin: 0; +} + +.quick-search .bx-loader { + margin-inline-end: 4px; +} diff --git a/apps/client/src/widgets/quick_search.ts b/apps/client/src/widgets/quick_search.ts index 33b770a745..e8f3add7fa 100644 --- a/apps/client/src/widgets/quick_search.ts +++ b/apps/client/src/widgets/quick_search.ts @@ -10,6 +10,7 @@ import { Dropdown, Tooltip } from "bootstrap"; const TPL = /*html*/`