feat: personalized search ranking — library > similar > neither

Migration 15: add in_library and is_similar INTEGER columns to
explore_index. Backfills in_library from existing library MBIDs.

Search index FTS5 query now includes personalization in scoring:
  ORDER BY bm25(...) - (ln(pop+1) * 1.5)
           - (in_library * 3.0) - (is_similar * 1.5)

For equal text+popularity scores:
  - Library artist beats unrelated by 3.0 points
  - Similar artist beats unrelated by 1.5 points
  - Library > Similar > Neither

Tier 3 (library) entries get in_library=1 via markInLibrary.
Tier 4 (similar) entries get is_similar=1 via markSimilar.

MB result reranking (boostWithIndexPopularity) adds a 10M
popularity bonus for library artists, ensuring they always
rank above non-library artists with equal text relevance.
This commit is contained in:
2026-03-28 12:50:57 -04:00
parent 15a0b94348
commit 7f0c6d362a
3 changed files with 153 additions and 7 deletions
+65
View File
@@ -390,6 +390,16 @@ func runMigrations(
}
}
// Migration 15: add in_library and is_similar columns to
// explore_index for personalized search ranking.
if version < 15 { //nolint:mnd
if err := migration15PersonalizationColumns(
ctx, db, logger,
); err != nil {
return err
}
}
return nil
}
@@ -1706,6 +1716,61 @@ func migration14ExploreAliases(
return nil
}
// migration15PersonalizationColumns adds in_library and is_similar
// columns to explore_index for personalized search ranking.
func migration15PersonalizationColumns(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 15: personalization columns")
for _, col := range []string{"in_library", "is_similar"} {
stmt := fmt.Sprintf(
"ALTER TABLE explore_index ADD COLUMN %s INTEGER NOT NULL DEFAULT 0", col,
)
if _, err := db.ExecContext(ctx, stmt); err != nil {
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("migration 15: alter explore_index: %w", err)
}
}
}
// Backfill in_library for artists already in the library.
if _, err := db.ExecContext(ctx, `
UPDATE explore_index SET in_library = 1
WHERE entity_type = 'artist'
AND mbid IN (SELECT mbid FROM artists WHERE mbid IS NOT NULL AND mbid != '')
`); err != nil {
logger.Warn("migration 15: backfill in_library artists", "error", err)
}
// Backfill in_library for release groups already in the library.
if _, err := db.ExecContext(ctx, `
UPDATE explore_index SET in_library = 1
WHERE entity_type = 'release_group'
AND mbid IN (SELECT mbid FROM release_groups WHERE mbid IS NOT NULL AND mbid != '')
`); err != nil {
logger.Warn("migration 15: backfill in_library release_groups", "error", err)
}
// Clear discog_built so the next index build populates these flags.
_, _ = db.ExecContext(ctx,
"DELETE FROM explore_index_meta WHERE key = 'discog_built'",
)
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 15",
); err != nil {
return fmt.Errorf("could not set user_version to 15: %w", err)
}
logger.Info("migration 15 complete")
return nil
}
// readLibraryDirFromTOML reads the TOML config file and returns
// the Library.DirectoryPath value, or "" if not configured.
func readLibraryDirFromTOML(logger *slog.Logger) string {
+16 -2
View File
@@ -737,10 +737,18 @@ const (
// is ready.
func (e *Service) boostWithIndexPopularity(result *MBSearchResult) {
// Look up popularity for all artist MBIDs.
// Give a large bonus to library artists so they rank first.
artistPop := make(map[string]int, len(result.Artists))
for _, a := range result.Artists {
if pop := e.index.GetPopularity(a.MBID); pop > 0 {
pop := e.index.GetPopularity(a.MBID)
// Library artists get a massive popularity bonus.
if e.index.IsInLibrary(a.MBID) {
pop += 10_000_000 //nolint:mnd
}
if pop > 0 {
artistPop[a.MBID] = pop
}
}
@@ -751,7 +759,13 @@ func (e *Service) boostWithIndexPopularity(result *MBSearchResult) {
rgPop := make(map[string]int, len(result.ReleaseGroups))
for _, rg := range result.ReleaseGroups {
if pop := e.index.GetPopularity(rg.MBID); pop > 0 {
pop := e.index.GetPopularity(rg.MBID)
if e.index.IsInLibrary(rg.MBID) {
pop += 10_000_000 //nolint:mnd
}
if pop > 0 {
rgPop[rg.MBID] = pop
}
}
+72 -5
View File
@@ -83,6 +83,8 @@ type SearchIndexResult struct {
Popularity int `json:"popularity"`
ExtraJSON string `json:"extraJson,omitempty"`
Aliases string `json:"aliases,omitempty"`
InLibrary bool `json:"inLibrary"`
IsSimilar bool `json:"isSimilar"`
}
// lbSitewideArtist is the response shape from the LB sitewide
@@ -217,6 +219,26 @@ func (si *SearchIndex) GetPopularity(mbid string) int {
return 0
}
// IsInLibrary returns whether the given MBID is marked as in the
// user's local library in the search index.
func (si *SearchIndex) IsInLibrary(mbid string) bool {
if mbid == "" {
return false
}
rows, err := si.db.QueryContext(
"SELECT in_library FROM explore_index WHERE mbid = ? AND in_library = 1 LIMIT 1",
mbid,
)
if err != nil {
return false
}
defer func() { _ = rows.Close() }()
return rows.Next()
}
// AddFromCache inserts entries from a cached discography browse
// into the search index (Tier 5: organic growth). Called when a
// user views an artist page and the discography is fetched.
@@ -277,11 +299,15 @@ func (si *SearchIndex) Search(query string, limit int) []SearchIndexResult {
rows, err := si.db.QueryContext(`
SELECT i.entity_type, i.mbid, i.title, i.artist_name,
i.artist_mbid, i.popularity, i.extra_json
i.artist_mbid, i.popularity, i.extra_json,
i.in_library, i.is_similar
FROM explore_index i
JOIN explore_index_fts f ON f.rowid = i.id
WHERE explore_index_fts MATCH ?
ORDER BY bm25(explore_index_fts, 3.0, 1.0, 0.5) - (ln(i.popularity + 1) * 1.5)
ORDER BY bm25(explore_index_fts, 3.0, 1.0, 0.5)
- (ln(i.popularity + 1) * 1.5)
- (i.in_library * 3.0)
- (i.is_similar * 1.5)
LIMIT ?
`, ftsQuery, limit)
if err != nil {
@@ -306,6 +332,7 @@ func (si *SearchIndex) Search(query string, limit int) []SearchIndexResult {
if err := rows.Scan(
&r.EntityType, &r.MBID, &r.Title, &r.ArtistName,
&r.ArtistMBID, &r.Popularity, &extraJSON,
&r.InLibrary, &r.IsSimilar,
); err != nil {
si.logger.Warn("search index scan error", "error", err)
@@ -802,6 +829,9 @@ func (si *SearchIndex) buildTier3Library(
if len(matched) > 0 {
si.indexArtistDiscographies(ctx, lb, matched, "Tier 3")
// Mark all Tier 3 entries as in_library.
si.markInLibrary(matched)
}
si.logger.Info("search index: Tier 3 matched",
@@ -895,6 +925,9 @@ func (si *SearchIndex) buildTier4Similar(
)
si.indexArtistDiscographies(ctx, lb, newArtists, "Tier 4")
// Mark all Tier 4 entries as similar.
si.markSimilar(newArtists)
}
type lbSimilarArtistWire struct {
@@ -1259,11 +1292,23 @@ func (si *SearchIndex) writeBatch(entries []SearchIndexResult) {
}
for _, e := range entries {
inLib := 0
if e.InLibrary {
inLib = 1
}
isSim := 0
if e.IsSimilar {
isSim = 1
}
if _, err := tx.Exec(`
INSERT OR REPLACE INTO explore_index
(entity_type, mbid, title, artist_name, artist_mbid, popularity, extra_json, aliases)
VALUES (?, ?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''))
`, e.EntityType, e.MBID, e.Title, e.ArtistName, e.ArtistMBID, e.Popularity, e.ExtraJSON, e.Aliases,
(entity_type, mbid, title, artist_name, artist_mbid,
popularity, extra_json, aliases, in_library, is_similar)
VALUES (?, ?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, ?)
`, e.EntityType, e.MBID, e.Title, e.ArtistName, e.ArtistMBID,
e.Popularity, e.ExtraJSON, e.Aliases, inLib, isSim,
); err != nil {
si.logger.Warn("search index: insert error",
"mbid", e.MBID,
@@ -1381,6 +1426,28 @@ func filterUnindexed(artists []lbSitewideArtist, indexed map[string]bool) []lbSi
return out
}
// markInLibrary sets in_library=1 for all index entries whose
// artist_mbid matches one of the given artists.
func (si *SearchIndex) markInLibrary(artists []lbSitewideArtist) {
for _, a := range artists {
_, _ = si.db.ExecContext(
"UPDATE explore_index SET in_library = 1 WHERE artist_mbid = ?",
a.ArtistMBID,
)
}
}
// markSimilar sets is_similar=1 for all index entries whose
// artist_mbid matches one of the given artists.
func (si *SearchIndex) markSimilar(artists []lbSitewideArtist) {
for _, a := range artists {
_, _ = si.db.ExecContext(
"UPDATE explore_index SET is_similar = 1 WHERE artist_mbid = ?",
a.ArtistMBID,
)
}
}
// InvalidateDiscographies clears the discography build timestamp
// so the next build re-runs Tiers 2-4.
func (si *SearchIndex) InvalidateDiscographies() {