titles > Content

This commit is contained in:
2026-05-01 14:34:15 +09:30
parent f6472ea9bd
commit 577478ba7e
3 changed files with 78 additions and 27 deletions
+1 -1
View File
@@ -84,7 +84,7 @@
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"color": "^5.0.0", "color": "^5.0.0",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"embeddia": "^1.2.1", "embeddia": "^1.3.0",
"embla-carousel-autoplay": "^8.5.2", "embla-carousel-autoplay": "^8.5.2",
"embla-carousel-svelte": "^8.5.2", "embla-carousel-svelte": "^8.5.2",
"esbuild": "^0.25.3", "esbuild": "^0.25.3",
@@ -197,11 +197,13 @@ export async function hybridSearch(
} }
} }
// Lexical guardrail: a strong title match is worth a meaningful // Lexical guardrail: title matches must outweigh fuzzy vector/content
// bonus so vector reranking can't quietly drop an exact assessment // overlap so exact titles lead the list.
// title between adjacent keystrokes. Scale is roughly 0..0.18.
const lexicalQuality = getLexicalMatchQuality(item, trimmedQuery); const lexicalQuality = getLexicalMatchQuality(item, trimmedQuery);
const lexicalBonus = lexicalQuality > 0 ? lexicalQuality / 80 : 0; let lexicalBonus = lexicalQuality > 0 ? lexicalQuality / 80 : 0;
if (lexicalQuality >= 12) lexicalBonus += 0.42;
else if (lexicalQuality >= 10) lexicalBonus += 0.24;
else if (lexicalQuality >= 8) lexicalBonus += 0.14;
const hybridScore = const hybridScore =
(normalizedBm25Score * opts.bm25Weight) + (normalizedBm25Score * opts.bm25Weight) +
@@ -11,6 +11,57 @@ import {
STRONG_LEXICAL_THRESHOLD, STRONG_LEXICAL_THRESHOLD,
} from "./lexicalMatch"; } from "./lexicalMatch";
/** Same normalization as lexical matching (trim + lowercase). */
function normSearchKey(s: string): string {
return s.trim().toLowerCase();
}
/**
* Exact title tiers so palette navigation (e.g. "Home", "Assessments") always
* wins over hybrid-scored body matches. Higher = sort earlier.
*/
function exactTitleSortTier(r: CombinedResult, queryNorm: string): number {
if (!queryNorm) return 0;
if (r.type === "command") {
const cmd = r.item as StaticCommandItem;
if (normSearchKey(cmd.text) !== queryNorm) return 0;
return cmd.category === "navigation" ? 3 : 2;
}
const ix = r.item as IndexItem;
if (normSearchKey(ix.text) === queryNorm) return 1;
return 0;
}
function compareCombinedSearchResults(
a: CombinedResult,
b: CombinedResult,
queryNorm: string,
): number {
const tierDiff = exactTitleSortTier(b, queryNorm) - exactTitleSortTier(a, queryNorm);
if (tierDiff !== 0) return tierDiff;
if (a.type === "command" && b.type === "dynamic") {
return b.score - a.score - 10;
}
if (a.type === "dynamic" && b.type === "command") {
return b.score - a.score + 10;
}
return b.score - a.score;
}
function syntheticIndexFromCommand(cmd: StaticCommandItem): IndexItem {
return {
id: cmd.id,
text: cmd.text,
category: cmd.category,
content: "",
dateAdded: 0,
metadata: {},
actionId: "",
renderComponentId: "",
};
}
// Search result cache for better performance // Search result cache for better performance
const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>(); const searchCache = new Map<string, { results: CombinedResult[]; timestamp: number }>();
const CACHE_TTL = 1000 * 60 * 5; // 5 minutes const CACHE_TTL = 1000 * 60 * 5; // 5 minutes
@@ -140,7 +191,19 @@ export function searchCommands(
return searchResults.map((result: FuseResult<StaticCommandItem>) => { return searchResults.map((result: FuseResult<StaticCommandItem>) => {
const item = result.item; const item = result.item;
const fuseScore = 15 * (1 - (result.score || 0.5)); const fuseScore = 15 * (1 - (result.score || 0.5));
const score = fuseScore + (item.priority ?? 0); let score = fuseScore + (item.priority ?? 0);
// Static palette titles share the same lexical tiers as index titles, but
// Fuse scores are tiny versus hybrid dynamic scores — scale title matches
// up so "Assessments" / prefix matches stay competitive with body hits.
const titleLex = getLexicalMatchQuality(syntheticIndexFromCommand(item), query);
if (titleLex >= 12) score += 240;
else if (titleLex >= 10) score += 195;
else if (titleLex >= 9) score += 165;
else if (titleLex >= 8) score += 140;
else if (titleLex >= 7) score += 120;
else if (titleLex >= 6) score += 100;
else if (titleLex > 0) score += titleLex * 14;
return { return {
id: item.id, id: item.id,
@@ -374,28 +437,14 @@ export async function performSearch(
// Step 4: Combine command and dynamic results // Step 4: Combine command and dynamic results
const allResults = [...commandResults, ...dynamicResults]; const allResults = [...commandResults, ...dynamicResults];
// Sort by score (commands typically have higher priority) allResults.sort((a, b) =>
allResults.sort((a, b) => { compareCombinedSearchResults(a, b, trimmedQuery),
// Commands always come first if scores are similar );
if (a.type === "command" && b.type === "dynamic") {
return b.score - a.score - 10; // Commands get +10 boost
}
if (a.type === "dynamic" && b.type === "command") {
return b.score - a.score + 10; // Commands get +10 boost
}
return b.score - a.score;
});
const dedupedResults = dedupeCombinedResultsByCourseNav(allResults); const dedupedResults = dedupeCombinedResultsByCourseNav(allResults);
dedupedResults.sort((a, b) => { dedupedResults.sort((a, b) =>
if (a.type === "command" && b.type === "dynamic") { compareCombinedSearchResults(a, b, trimmedQuery),
return b.score - a.score - 10; );
}
if (a.type === "dynamic" && b.type === "command") {
return b.score - a.score + 10;
}
return b.score - a.score;
});
// Cache results for queries longer than 2 chars // Cache results for queries longer than 2 chars
if (trimmedQuery.length > 2) { if (trimmedQuery.length > 2) {