feat: score threshold + server-side result caps

Drop artists and recordings with blended score < 25 after popularity
reranking. Cap each entity slice to 15 server-side. Request 20 from
MB to allow filtering headroom. Reduces payload size and noise.
This commit is contained in:
2026-03-24 22:42:54 -04:00
parent 5f8f6d6a26
commit 1e95ddee68
+67 -6
View File
@@ -55,17 +55,17 @@ func (e *Service) SetContext(ctx context.Context) {
// SearchArtists queries MusicBrainz for artists matching the query. // SearchArtists queries MusicBrainz for artists matching the query.
func (e *Service) SearchArtists(query string) ([]MBArtist, error) { func (e *Service) SearchArtists(query string) ([]MBArtist, error) {
return e.mb.SearchArtists(e.ctx, query, 0) return e.mb.SearchArtists(e.ctx, query, mbSearchLimit)
} }
// SearchReleaseGroups queries MusicBrainz for release groups matching the query. // SearchReleaseGroups queries MusicBrainz for release groups matching the query.
func (e *Service) SearchReleaseGroups(query string) ([]MBReleaseGroup, error) { func (e *Service) SearchReleaseGroups(query string) ([]MBReleaseGroup, error) {
return e.mb.SearchReleaseGroups(e.ctx, query, 0) return e.mb.SearchReleaseGroups(e.ctx, query, mbSearchLimit)
} }
// SearchRecordings queries MusicBrainz for recordings matching the query. // SearchRecordings queries MusicBrainz for recordings matching the query.
func (e *Service) SearchRecordings(query string) ([]MBRecording, error) { func (e *Service) SearchRecordings(query string) ([]MBRecording, error) {
return e.mb.SearchRecordings(e.ctx, query, 0) return e.mb.SearchRecordings(e.ctx, query, mbSearchLimit)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -155,7 +155,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
{ {
name: "artists", name: "artists",
fn: func() { fn: func() {
artists, err := e.mb.SearchArtists(e.ctx, query, 0) artists, err := e.mb.SearchArtists(e.ctx, query, mbSearchLimit)
if err != nil { if err != nil {
e.logger.Warn("search sub-call failed", e.logger.Warn("search sub-call failed",
"entity", "artists", "entity", "artists",
@@ -174,7 +174,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
{ {
name: "releaseGroups", name: "releaseGroups",
fn: func() { fn: func() {
rgs, err := e.mb.SearchReleaseGroups(e.ctx, query, 0) rgs, err := e.mb.SearchReleaseGroups(e.ctx, query, mbSearchLimit)
if err != nil { if err != nil {
e.logger.Warn("search sub-call failed", e.logger.Warn("search sub-call failed",
"entity", "releaseGroups", "entity", "releaseGroups",
@@ -193,7 +193,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
{ {
name: "recordings", name: "recordings",
fn: func() { fn: func() {
recs, err := e.mb.SearchRecordings(e.ctx, query, 0) recs, err := e.mb.SearchRecordings(e.ctx, query, mbSearchLimit)
if err != nil { if err != nil {
e.logger.Warn("search sub-call failed", e.logger.Warn("search sub-call failed",
"entity", "recordings", "entity", "recordings",
@@ -235,6 +235,9 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
// overlap on different rate-limiter tokens. // overlap on different rate-limiter tokens.
e.boostWithPopularity(&result) e.boostWithPopularity(&result)
// Phase 3: filter low-scoring results and cap counts.
filterAndCap(&result)
e.logger.Info("search completed", e.logger.Info("search completed",
"query", query, "query", query,
"artists", len(result.Artists), "artists", len(result.Artists),
@@ -245,6 +248,53 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
return &result, nil return &result, nil
} }
// ---------------------------------------------------------------------------
// Filtering and capping
// ---------------------------------------------------------------------------
// filterAndCap removes low-scoring results and limits each entity
// slice to maxResults entries.
func filterAndCap(result *MBSearchResult) {
// Filter artists by minimum blended score.
if len(result.Artists) > 0 {
filtered := result.Artists[:0]
for _, a := range result.Artists {
if a.Score >= minBlendedScore {
filtered = append(filtered, a)
}
}
result.Artists = filtered
}
// Filter recordings by minimum blended score.
if len(result.Recordings) > 0 {
filtered := result.Recordings[:0]
for _, r := range result.Recordings {
if r.Score >= minBlendedScore {
filtered = append(filtered, r)
}
}
result.Recordings = filtered
}
// Cap each slice.
if len(result.Artists) > maxResults {
result.Artists = result.Artists[:maxResults]
}
if len(result.ReleaseGroups) > maxResults {
result.ReleaseGroups = result.ReleaseGroups[:maxResults]
}
if len(result.Recordings) > maxResults {
result.Recordings = result.Recordings[:maxResults]
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Popularity-boosted reranking // Popularity-boosted reranking
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -253,6 +303,17 @@ const (
// Blending weights for final score. // Blending weights for final score.
relevanceWeight = 0.6 relevanceWeight = 0.6
popularityWeight = 0.4 popularityWeight = 0.4
// mbSearchLimit is passed to each MB search call. Slightly
// larger than maxResults to allow headroom for filtering.
mbSearchLimit = 20
// maxResults caps each entity slice after filtering.
maxResults = 15
// minBlendedScore is the floor for artists and recordings
// after popularity reranking (0100 scale).
minBlendedScore = 25
) )
// boostWithPopularity fetches ListenBrainz listen counts for all // boostWithPopularity fetches ListenBrainz listen counts for all