feat: exact-match-first ranking in search and wikilink autocomplete

Add searchRank/bestSearchRank utilities that compute a tier (0=exact,
1=prefix, 2=fuzzy-only). Both useNoteSearch and WikilinkChatInput now
sort by rank tier first, then by fuzzy score, ensuring notes with exact
title or alias matches always surface above partial/fuzzy matches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-08 21:00:58 +01:00
parent dd7f0f978a
commit 4ce9167e09
5 changed files with 110 additions and 11 deletions

View File

@@ -8,6 +8,7 @@ import { useState, useRef, useMemo, useEffect } from 'react'
import type { VaultEntry } from '../types'
import type { NoteReference } from '../utils/ai-context'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { bestSearchRank } from '../utils/fuzzyMatch'
const MAX_SUGGESTIONS = 20
const MIN_QUERY_LENGTH = 1
@@ -46,13 +47,16 @@ function matchEntries(
): SuggestionEntry[] {
if (query.length < MIN_QUERY_LENGTH) return []
const lower = query.toLowerCase()
const matches = entries.filter(e =>
!e.trashed && !e.archived && (
e.title.toLowerCase().includes(lower) ||
e.aliases.some(a => a.toLowerCase().includes(lower))
),
)
return matches.slice(0, MAX_SUGGESTIONS).map(e => {
const matches = entries
.filter(e =>
!e.trashed && !e.archived && (
e.title.toLowerCase().includes(lower) ||
e.aliases.some(a => a.toLowerCase().includes(lower))
),
)
.map(e => ({ entry: e, rank: bestSearchRank(query, e.title, e.aliases) }))
.sort((a, b) => a.rank - b.rank)
return matches.slice(0, MAX_SUGGESTIONS).map(({ entry: e }) => {
const te = typeEntryMap[e.isA ?? '']
return {
title: e.title,

View File

@@ -175,6 +175,38 @@ describe('useNoteSearch', () => {
expect(preventDefaultSpy).not.toHaveBeenCalled()
})
it('ranks exact title match first, prefix second, fuzzy third', () => {
const ranked: VaultEntry[] = [
makeEntry({ path: '/vault/ri.md', title: 'Refactoring Ideas', modifiedAt: 1700000003 }),
makeEntry({ path: '/vault/rk.md', title: 'Refactoring Key Ideas', modifiedAt: 1700000002 }),
makeEntry({ path: '/vault/r.md', title: 'Refactoring', modifiedAt: 1700000001 }),
]
const { result } = renderHook(() => useNoteSearch(ranked, 'Refactoring'))
expect(result.current.results.map(r => r.title)).toEqual([
'Refactoring',
'Refactoring Ideas',
'Refactoring Key Ideas',
])
})
it('ranks case-insensitive exact match first', () => {
const ranked: VaultEntry[] = [
makeEntry({ path: '/vault/qi.md', title: 'Quarter Ideas', modifiedAt: 1700000002 }),
makeEntry({ path: '/vault/q.md', title: 'Quarter', modifiedAt: 1700000001 }),
]
const { result } = renderHook(() => useNoteSearch(ranked, 'quarter'))
expect(result.current.results[0].title).toBe('Quarter')
})
it('boosts note whose alias is an exact match', () => {
const ranked: VaultEntry[] = [
makeEntry({ path: '/vault/ri.md', title: 'Refactoring Ideas', modifiedAt: 1700000002 }),
makeEntry({ path: '/vault/rn.md', title: 'Refactoring Notes', aliases: ['ref'], modifiedAt: 1700000001 }),
]
const { result } = renderHook(() => useNoteSearch(ranked, 'ref'))
expect(result.current.results[0].title).toBe('Refactoring Notes')
})
it('resolves custom type color from Type entries', () => {
const withTypes: VaultEntry[] = [
makeEntry({ path: '/vault/t/recipe.md', title: 'Recipe', isA: 'Type', color: 'orange', icon: 'cooking-pot' }),

View File

@@ -1,6 +1,6 @@
import { useState, useMemo, useCallback, useEffect } from 'react'
import type { VaultEntry } from '../types'
import { fuzzyMatch } from '../utils/fuzzyMatch'
import { fuzzyMatch, bestSearchRank } from '../utils/fuzzyMatch'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { getTypeIcon } from '../components/NoteItem'
import type { NoteSearchResultItem } from '../components/NoteSearchList'
@@ -45,9 +45,13 @@ export function useNoteSearch(entries: VaultEntry[], query: string, maxResults =
.map(mapResult)
}
return searchableEntries
.map((e) => ({ entry: e, ...fuzzyMatch(query, e.title) }))
.map((e) => ({
entry: e,
...fuzzyMatch(query, e.title),
rank: bestSearchRank(query, e.title, e.aliases),
}))
.filter((r) => r.match)
.sort((a, b) => b.score - a.score)
.sort((a, b) => a.rank - b.rank || b.score - a.score)
.slice(0, maxResults)
.map((r) => mapResult(r.entry))
}, [searchableEntries, query, maxResults, typeEntryMap])

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { fuzzyMatch } from './fuzzyMatch'
import { fuzzyMatch, searchRank, bestSearchRank } from './fuzzyMatch'
describe('fuzzyMatch', () => {
it('matches exact string', () => {
@@ -44,3 +44,43 @@ describe('fuzzyMatch', () => {
expect(fuzzyMatch('a', '').match).toBe(false)
})
})
describe('searchRank', () => {
it('returns 0 for exact match', () => {
expect(searchRank('Refactoring', 'Refactoring')).toBe(0)
})
it('returns 0 for case-insensitive exact match', () => {
expect(searchRank('refactoring', 'Refactoring')).toBe(0)
})
it('returns 1 for prefix match', () => {
expect(searchRank('Refactoring', 'Refactoring Ideas')).toBe(1)
})
it('returns 1 for case-insensitive prefix match', () => {
expect(searchRank('quarter', 'Quarter Review')).toBe(1)
})
it('returns 2 for non-prefix fuzzy match', () => {
expect(searchRank('Ideas', 'Refactoring Ideas')).toBe(2)
})
})
describe('bestSearchRank', () => {
it('returns best rank across title and aliases', () => {
expect(bestSearchRank('ref', 'Refactoring Notes', ['ref'])).toBe(0)
})
it('returns title rank when no aliases match better', () => {
expect(bestSearchRank('Refactoring', 'Refactoring', [])).toBe(0)
})
it('prefers alias exact match over title prefix match', () => {
expect(bestSearchRank('ref', 'Reference Manual', ['ref'])).toBe(0)
})
it('returns 2 when nothing matches as exact or prefix', () => {
expect(bestSearchRank('ideas', 'Refactoring Ideas', ['thoughts'])).toBe(2)
})
})

View File

@@ -1,3 +1,22 @@
/** Search rank tier: 0 = exact match, 1 = prefix match, 2 = fuzzy only. Lower is better. */
export function searchRank(query: string, target: string): number {
const q = query.toLowerCase()
const t = target.toLowerCase()
if (t === q) return 0
if (t.startsWith(q)) return 1
return 2
}
/** Best rank across a title and its aliases. */
export function bestSearchRank(query: string, title: string, aliases: string[]): number {
let rank = searchRank(query, title)
for (const alias of aliases) {
rank = Math.min(rank, searchRank(query, alias))
if (rank === 0) break
}
return rank
}
/** Fuzzy match: all query chars must appear in order in the target. */
export function fuzzyMatch(query: string, target: string): { match: boolean; score: number } {
const q = query.toLowerCase()