mirror of
https://github.com/openclaw/clawhub.git
synced 2026-07-22 03:35:29 -04:00
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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user