fix: overhaul search relevance scoring to prioritize exact matches

- Require ALL query tokens to match (was: only ONE), preventing false positives
- Add scoreTokenMatch for granular lexical scoring (exact > prefix > partial)
- Double+ lexical boost weights for slug/name matches
- Add summary text matching to scoring pipeline
- Update tests for new matching behavior

Fixes #15
This commit is contained in:
Mike Bundy
2026-02-13 11:11:44 -08:00
parent 99645d2c27
commit 025f665802
3 changed files with 92 additions and 15 deletions
+23 -6
View File
@@ -1,7 +1,7 @@
/* @vitest-environment node */
import { describe, expect, it } from 'vitest'
import { __test, matchesExactTokens, tokenize } from './searchText'
import { __test, matchesExactTokens, scoreTokenMatch, tokenize } from './searchText'
describe('searchText', () => {
it('tokenize lowercases and splits on punctuation', () => {
@@ -13,15 +13,18 @@ describe('searchText', () => {
])
})
it('matchesExactTokens requires at least one query token to prefix-match', () => {
it('matchesExactTokens requires ALL query tokens to prefix-match', () => {
const queryTokens = tokenize('Remind Me')
// Both "remind" and "me" match
expect(matchesExactTokens(queryTokens, ['Remind Me', '/remind-me', 'Short summary'])).toBe(true)
// "Reminder" starts with "remind", so it matches with prefix matching
// "reminder" starts with "remind", but no "me" token
expect(matchesExactTokens(queryTokens, ['Reminder tool', '/reminder', 'Short summary'])).toBe(
true,
false,
)
// Matches because "remind" token is present
expect(matchesExactTokens(queryTokens, ['Remind tool', '/remind', 'Short summary'])).toBe(true)
// "remind" matches but no "me" token either
expect(matchesExactTokens(queryTokens, ['Remind tool', '/remind', 'Short summary'])).toBe(false)
// "remind" + "me" present in summary
expect(matchesExactTokens(queryTokens, ['Remind tool', '/remind', 'Reminds me of things'])).toBe(true)
// No matching tokens at all
expect(matchesExactTokens(queryTokens, ['Other tool', '/other', 'Short summary'])).toBe(false)
})
@@ -37,6 +40,20 @@ describe('searchText', () => {
expect(matchesExactTokens(['notion'], ['Annotations helper', '/annotations'])).toBe(false)
})
it('scoreTokenMatch returns higher scores for better matches', () => {
const queryTokens = tokenize('Remind Me')
// Exact match on both tokens
expect(scoreTokenMatch(queryTokens, ['Remind Me'])).toBeGreaterThan(0)
// Only one token matches — below threshold for 2-token query
expect(scoreTokenMatch(queryTokens, ['Other tool with me'])).toBe(0)
// Both match (prefix)
const bothMatch = scoreTokenMatch(queryTokens, ['Reminder mechanism'])
expect(bothMatch).toBeGreaterThan(0)
// Exact > prefix
const exactBoth = scoreTokenMatch(queryTokens, ['remind me'])
expect(exactBoth).toBeGreaterThan(bothMatch)
})
it('matchesExactTokens ignores empty inputs', () => {
expect(matchesExactTokens([], ['text'])).toBe(false)
expect(matchesExactTokens(['token'], [' ', null, undefined])).toBe(false)
+57 -3
View File
@@ -9,6 +9,59 @@ export function tokenize(value: string): string[] {
return normalize(value).match(WORD_RE) ?? []
}
/**
* Returns a match score (0 = no match, higher = better) based on how well
* the query tokens match the candidate text parts.
*
* Scoring:
* - Each query token that exactly matches a text token: +2
* - Each query token that prefix-matches a text token: +1
* - Bonus if ALL query tokens match: +3
*
* Returns 0 if fewer than half the query tokens match (for single-token queries, must match).
*/
export function scoreTokenMatch(
queryTokens: string[],
parts: Array<string | null | undefined>,
): number {
if (queryTokens.length === 0) return 0
const text = parts.filter((part) => Boolean(part?.trim())).join(' ')
if (!text) return 0
const textTokens = tokenize(text)
if (textTokens.length === 0) return 0
let score = 0
let matched = 0
for (const queryToken of queryTokens) {
const hasExact = textTokens.some((t) => t === queryToken)
if (hasExact) {
score += 2
matched++
} else {
const hasPrefix = textTokens.some((t) => t.startsWith(queryToken))
if (hasPrefix) {
score += 1
matched++
}
}
}
// Require majority of query tokens to match (all for 1-2 token queries)
const minRequired = queryTokens.length <= 2 ? queryTokens.length : Math.ceil(queryTokens.length * 0.6)
if (matched < minRequired) return 0
// Bonus for all tokens matching
if (matched === queryTokens.length) score += 3
return score
}
/**
* Legacy boolean check — returns true if ALL query tokens prefix-match
* at least one text token. This is stricter than the old behavior
* (which only required ONE token) to prevent false positives.
*/
export function matchesExactTokens(
queryTokens: string[],
parts: Array<string | null | undefined>,
@@ -18,10 +71,11 @@ export function matchesExactTokens(
if (!text) return false
const textTokens = tokenize(text)
if (textTokens.length === 0) return false
// Require at least one token to prefix-match, allowing vector similarity to determine relevance
return queryTokens.some((queryToken) =>
// Require ALL query tokens to prefix-match at least one text token.
// This prevents "Remind Me" from matching skills that only contain "me".
return queryTokens.every((queryToken) =>
textTokens.some((textToken) => textToken.startsWith(queryToken)),
)
}
export const __test = { normalize, tokenize, matchesExactTokens }
export const __test = { normalize, tokenize, matchesExactTokens, scoreTokenMatch }
+12 -6
View File
@@ -5,7 +5,7 @@ import { action, internalQuery } from './_generated/server'
import { getSkillBadgeMaps, isSkillHighlighted, type SkillBadgeMap } from './lib/badges'
import { generateEmbedding } from './lib/embeddings'
import { toPublicSkill, toPublicSoul } from './lib/public'
import { matchesExactTokens, tokenize } from './lib/searchText'
import { matchesExactTokens, scoreTokenMatch, tokenize } from './lib/searchText'
import { isSkillSuspicious } from './lib/skillSafety'
type SkillSearchEntry = {
@@ -17,10 +17,11 @@ type SkillSearchEntry = {
type SearchResult = SkillSearchEntry & { score: number }
const SLUG_EXACT_BOOST = 1.4
const SLUG_PREFIX_BOOST = 0.8
const NAME_EXACT_BOOST = 1.1
const NAME_PREFIX_BOOST = 0.6
const SLUG_EXACT_BOOST = 3.0
const SLUG_PREFIX_BOOST = 1.5
const NAME_EXACT_BOOST = 2.5
const NAME_PREFIX_BOOST = 1.2
const SUMMARY_MATCH_WEIGHT = 0.3
const POPULARITY_WEIGHT = 0.08
const FALLBACK_SCAN_LIMIT = 1200
@@ -70,10 +71,14 @@ function scoreSkillResult(
displayName: string,
slug: string,
downloads: number,
summary?: string,
) {
const lexicalBoost = getLexicalBoost(queryTokens, displayName, slug)
const summaryScore = summary
? scoreTokenMatch(queryTokens, [summary]) * SUMMARY_MATCH_WEIGHT
: 0
const popularityBoost = Math.log1p(Math.max(downloads, 0)) * POPULARITY_WEIGHT
return vectorScore + lexicalBoost + popularityBoost
return vectorScore + lexicalBoost + summaryScore + popularityBoost
}
function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearchEntry[]) {
@@ -188,6 +193,7 @@ export const searchSkills: ReturnType<typeof action> = action({
entry.skill.displayName,
entry.skill.slug,
entry.skill.stats.downloads,
entry.skill.summary,
),
}
})