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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user