feat: AND + wildcard Lucene queries, fuzzy library search, limit 50

Three search improvements:

1. MB queries now use AND + wildcard syntax instead of default OR.
   'the teenagers' → 'the AND teenagers*'. This eliminates common-
   word pollution: The Beatles no longer match because they only
   contain 'the'. The trailing wildcard on the last term preserves
   type-ahead behavior. Special Lucene characters are escaped.

2. mbSearchLimit increased from 20 to 50. Gives the ranking pipeline
   more raw material — with AND filtering there's less noise, and
   our name-match tiers + popularity reranking handle the rest.
   Final display is still capped at 15.

3. Frontend library cache now uses fuzzy matching with Levenshtein
   edit distance (max 2) as fallback. Exact substring match is
   tried first, then per-word fuzzy matching for words >= 4 chars.
   'florene and the machine' matches 'Florence and the Machine'.
   Pure JS, no API cost — runs against the in-memory library arrays.
This commit is contained in:
2026-03-30 02:26:55 -04:00
parent 920a96d9b1
commit 52632b470c
2 changed files with 137 additions and 9 deletions
+84 -7
View File
@@ -329,7 +329,11 @@ func (e *Service) GetArtistImages(names []string) map[string]string {
// failures degrade to MB-only ordering.
func (e *Service) Search(query string) (*MBSearchResult, error) {
searchStart := time.Now()
e.logger.Info("search started", "query", query)
// Build the Lucene query: AND terms with wildcard on last.
luceneQuery := buildLuceneQuery(query)
e.logger.Info("search started", "query", query, "lucene", luceneQuery)
// Phase 0: query local popularity index (instant, no API calls).
p0Start := time.Now()
@@ -365,7 +369,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
name: "artists",
fn: func() {
t := time.Now()
artists, err := e.mb.SearchArtists(mbCtx, query, mbSearchLimit)
artists, err := e.mb.SearchArtists(mbCtx, luceneQuery, mbSearchLimit)
e.logger.Info("search MB sub-call",
"entity", "artists",
@@ -392,7 +396,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
name: "releaseGroups",
fn: func() {
t := time.Now()
rgs, err := e.mb.SearchReleaseGroups(mbCtx, query, mbSearchLimit)
rgs, err := e.mb.SearchReleaseGroups(mbCtx, luceneQuery, mbSearchLimit)
e.logger.Info("search MB sub-call",
"entity", "releaseGroups",
@@ -419,7 +423,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
name: "recordings",
fn: func() {
t := time.Now()
recs, err := e.mb.SearchRecordings(mbCtx, query, mbSearchLimit)
recs, err := e.mb.SearchRecordings(mbCtx, luceneQuery, mbSearchLimit)
e.logger.Info("search MB sub-call",
"entity", "recordings",
@@ -872,9 +876,10 @@ const (
relevanceWeight = 0.4
popularityWeight = 0.6
// mbSearchLimit is passed to each MB search call. Slightly
// larger than maxResults to allow headroom for filtering.
mbSearchLimit = 20
// mbSearchLimit is passed to each MB search call. Larger than
// maxResults to give the ranking pipeline more raw material.
// Noise is filtered out by name-match tiers and score cutoffs.
mbSearchLimit = 50
// searchMBTimeout is the maximum time to wait for MusicBrainz
// API responses during interactive search. If MB is slow,
@@ -1308,3 +1313,75 @@ func maxListenCount(pop map[string]int) int {
return maxVal
}
// ---------------------------------------------------------------------------
// Lucene query building
// ---------------------------------------------------------------------------
// luceneSpecialChars are characters that have special meaning in
// Lucene query syntax and must be escaped in user input.
var luceneSpecialChars = strings.NewReplacer( //nolint:gochecknoglobals
`\`, `\\`,
`+`, `\+`,
`-`, `\-`,
`!`, `\!`,
`(`, `\(`,
`)`, `\)`,
`{`, `\{`,
`}`, `\}`,
`[`, `\[`,
`]`, `\]`,
`^`, `\^`,
`"`, `\"`,
`~`, `\~`,
`*`, `\*`,
`?`, `\?`,
`:`, `\:`,
`/`, `\/`,
)
// buildLuceneQuery converts a user's search input into a Lucene
// AND query with a wildcard on the last term for type-ahead.
//
// Examples:
//
// "radiohead" → "radiohead*"
// "the teenagers" → "the AND teenagers*"
// "florence machine" → "florence AND machine*"
// "ac/dc" → "ac\/dc*"
//
// This eliminates the common-word pollution problem: "the teenagers"
// no longer matches "The Beatles" (which only contains "the").
// The trailing wildcard enables prefix matching as the user types.
func buildLuceneQuery(input string) string {
words := strings.Fields(strings.TrimSpace(input))
if len(words) == 0 {
return ""
}
// Escape special Lucene characters in each word.
for i, w := range words {
words[i] = luceneSpecialChars.Replace(w)
}
if len(words) == 1 {
return words[0] + "*"
}
// AND all terms, wildcard on the last (type-ahead).
var b strings.Builder
for i, w := range words {
if i > 0 {
b.WriteString(" AND ")
}
b.WriteString(w)
if i == len(words)-1 {
b.WriteByte('*')
}
}
return b.String()
}
@@ -16,6 +16,57 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
/* ── Constants ── */
const DEBOUNCE_MS = 300;
const MIN_QUERY_LENGTH = 2;
const FUZZY_MAX_DISTANCE = 2;
/* ── Fuzzy matching ── */
/** Levenshtein edit distance between two strings. */
function editDistance(a: string, b: string): number {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix: number[][] = [];
for (let i = 0; i <= a.length; i++) matrix[i] = [i];
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost,
);
}
}
return matrix[a.length][b.length];
}
/**
* Check if a name fuzzy-matches a query. Returns true if:
* - the name contains the query as a substring (exact), OR
* - any word-aligned segment of the name is within edit distance
* FUZZY_MAX_DISTANCE of the query
*/
function fuzzyMatch(query: string, name: string): boolean {
if (name.includes(query)) return true;
// Split both into words and check if all query words match
// a name word within edit distance (handles per-word typos).
const qWords = query.split(/\s+/);
const nWords = name.split(/\s+/);
return qWords.every((qw) =>
nWords.some(
(nw) =>
nw.includes(qw) ||
qw.includes(nw) ||
(qw.length >= 4 && editDistance(qw, nw) <= FUZZY_MAX_DISTANCE),
),
);
}
const MAX_SECTION_RESULTS = 10;
const CAA_GROUP_BASE = 'https://coverartarchive.org/release-group';
@@ -556,7 +607,7 @@ export class ExploreView extends LitElement {
const cachedArtists = libraryStore.cachedArtists;
if (cachedArtists) {
for (const a of cachedArtists) {
if (a.Name.toLowerCase().includes(q)) {
if (fuzzyMatch(q, a.Name.toLowerCase())) {
artists.push({
mbid: a.MBID || '',
name: a.Name,
@@ -578,7 +629,7 @@ export class ExploreView extends LitElement {
const cachedAlbums = libraryStore.cachedAlbums;
if (cachedAlbums) {
for (const a of cachedAlbums) {
if (a.Name.toLowerCase().includes(q) || a.ArtistName.toLowerCase().includes(q)) {
if (fuzzyMatch(q, a.Name.toLowerCase()) || fuzzyMatch(q, a.ArtistName.toLowerCase())) {
releaseGroups.push({
mbid: a.MBID || '',
title: a.Name,