# Embeat：基于声学特征的歌曲推荐网络

## 开源仓库

[https://github.com/gdstudio-org/Embeat](https://github.com/gdstudio-org/Embeat)

## 项目初衷

*   被Spotify的歌曲推荐机制震撼到，能给出非常精准的推荐，即便推荐的冷门歌曲，也非常对胃
    
*   我的GD音乐台没有推荐机制，只能播放用户搜索结果或用户自定义歌单，缺乏交互性
    
*   一直都想尝试自己从零开始构建模型相关代码，这种实践比微调模型能学到更多东西
    
*   项目开始时间：2026-01-22
    
*   V1.0开源时间：2026-06-26
    

## Spotify的推荐机制

[https://medium.com/biased-algorithms/contrastive-learning-for-sequential-recommendation-f4744d75128a](https://medium.com/biased-algorithms/contrastive-learning-for-sequential-recommendation-f4744d75128a)

[https://www.music-tomorrow.com/blog/how-spotify-recommendation-system-works-complete-guide](https://www.music-tomorrow.com/blog/how-spotify-recommendation-system-works-complete-guide) 

**协同过滤与矩阵分解**

*   参考文章：[https://medium.com/@rohitlokwani17/evolution-of-collaborative-filtering-algorithms-with-implicit-feedback-for-large-scale-recommended-e7b2e2d18d99](https://medium.com/@rohitlokwani17/evolution-of-collaborative-filtering-algorithms-with-implicit-feedback-for-large-scale-recommended-e7b2e2d18d99)
    
*   如果用户 A 和用户 B 在过去喜欢相似的歌曲，那么用户 A 很可能也会喜欢用户 B 喜欢的其他歌曲
    
*   早期的推荐系统常使用基于近邻的方法，但在亿级规模下，计算用户间相似度的成本过高。Spotify 转向了基于模型的协同过滤，特别是矩阵分解技术
    
*   使用两个因子矩阵代表用户听歌习惯与歌曲风格，如使用两个 40 \* 256 维的向量，其中256是embedding维度，40代表了一些隐含信息，如歌曲energy、happiness等（实际上没有具体的语义标签，是模型自己学习出来的）。然后计算用户矩阵与歌曲矩阵的乘积得到推荐度得分
    
*   实际举例：用户喜欢听摇滚，他的energy为0.8。一首摇滚曲的energy为0.9，而摇篮曲的energy为0.1，用户矩阵与歌曲矩阵相乘，摇滚曲的得分更高，因此系统倾向于推荐那首摇滚曲
    
*   带条件的矩阵分解：引入两个偏置项，用户偏置Bu与歌曲偏置Bi。对于Bu，某些用户天生听歌更多，偏置项确保模型不会仅仅因为某人听得多就认为他对所有歌曲都有高偏好；对于Bi，热门歌曲被播放的概率天然更高。模型需要剥离这种“全局流行度”，以挖掘用户独特的个性化品味 
    
*   为了在数亿用户和歌曲上训练该模型，Spotify采用了交替梯度下降，利用Hadoop和Spark集群进行大规模并行计算
    
*   局限性：小众歌曲难以突围、缺乏上下文联系（无法很好说明为什么两首歌曲相关）
    

![image (2).png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/96b278f1-d69e-439a-b6a4-6637f1b080e8.png)

**NLP自然语言处理**

*   参考文章：[https://beatstorapon.com/blog/ultimate-guide-to-spotify-music-algorithm/](https://beatstorapon.com/blog/ultimate-guide-to-spotify-music-algorithm/)
    
*   Spotify的爬虫全天候扫描互联网上的公开文本资源，构建庞大的音乐语料库。主要来源包括：音乐博客与评论网站、新闻与媒体报道、社交媒体讨论、论坛帖子、用户播放列表标题
    
*   引申：Spotify没有评论区，理论上网易云也可以爬取自己用户发表的评论，来理解歌曲的社交属性
    
*   Spotify当时做这部分研究还比较早，用的是word2vec，通过文本数据清洗提取关键词，得到歌曲与关键词的匹配关系。举例：如果Daft Punk经常与French House、Robot、Funky同时出现，这些词就构成了该艺人的语义描述向量
    
*   这些向量是时变的。如果一位流行歌手突然发布了一张实验性的爵士专辑，互联网上的讨论词汇会发生变化，NLP模型会捕捉到这种风格转移，并迅速调整该艺人在向量空间中的位置，从而将其推荐给爵士乐迷，而不仅仅是其原本的流行乐迷 
    
*   这里贴一个网站，前Spotify研究员制作的歌手与风格的匹配关系：[https://everynoise.com/](https://everynoise.com/)
    

![image (2).png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/b22381be-22cd-4f89-b86d-da574d5b1b97.png)

**原始音频分析**

*   参考文章：[https://onlyoneaman.medium.com/unleashing-the-power-of-audio-features-with-the-spotify-api-c544fda1af40](https://onlyoneaman.medium.com/unleashing-the-power-of-audio-features-with-the-spotify-api-c544fda1af40)
    
*   Spotify的杀手锏，同时也是Embeat项目最重要的技术支柱
    
*   Spotify为开发者提供了一个用于获取音频特征的API，但在25年2月就关闭了。API输入Spotify的track\_id，输出以下内容：
    

```markdown
Key (调性)：如C、D、E调
Mode (调式)：大调、小调
Tempo (速度)：如120BPM
Time Signature (节拍)：如四三拍、四四拍
Loudness (响度)：基于 EBU R.128 标准的响度值
Danceability (可舞性)：基于节拍稳定性、强度
Energy (能量)：强度、响度、动态范围
Valence (情绪效价)：音乐的积极/消极程度（快乐 vs 悲伤）
Instrumentalness (器乐性)：人声占比
Acousticness (原声性)：是否使用电子合成器
Liveness (现场感)：是否为现场录音
speechiness (语音度)：是否为说话语音（如朗诵）
```

## 核心数据

**数据来源**

*   去年12月底，从小飞机群了解到某黑客组织公开了Spotify的全站元数据，后续据说还会公开所有歌曲的160kbps ogg数据。其中元数据120GB，未公开的歌曲数据300TB
    
*   黑客公开的元数据包含这几大类：歌曲基本信息、歌手基本信息、专辑基本信息、歌曲音频分析
    
*   元数据内容：（两份主要文件）
    

```sql
CREATE TABLE `artists` (
  `rowid` integer PRIMARY KEY NOT NULL,
  /* The original Spotify base62 ID. */
  `id` text NOT NULL,
  /* When the item was fetched (unixepoch ms). */
  `fetched_at` integer NOT NULL,
  /* "The name of the artist."*/
  `name` text NOT NULL,
  /* followers.total - "The total number of followers." */
  `followers_total` integer NOT NULL,
  /* "The popularity of the artist. The value will be between 0 and 100, with 100 being the most popular. The artist's popularity is calculated from the popularity of all the artist's tracks." */
  `popularity` integer NOT NULL
  );
/* "A list of the genres the artist is associated with. If not yet classified, the array is empty." */
CREATE TABLE `artist_genres` (
  `artist_rowid` integer NOT NULL,
  `genre` text NOT NULL,
  FOREIGN KEY (`artist_rowid`) REFERENCES `artists`(`rowid`)
  );
/* Images of the artist in various sizes, widest first. */
CREATE TABLE `artist_images` (
  `artist_rowid` integer NOT NULL,
  `width` integer NOT NULL,
  `height` integer NOT NULL,
  `url` text NOT NULL,
  FOREIGN KEY (`artist_rowid`) REFERENCES `artists`(`rowid`)
  );
/*

* Information about artist-albums relationships from /artists/{id}/albums, album.artists[ ] and album.tracks[ ].artists[ ].

* The relationships "album", "single", "compilation" are left out because they can be reconstructed from `album.type`.
*/
CREATE TABLE "artist_albums" (
  `artist_rowid` integer NOT NULL,
  `album_rowid` integer NOT NULL,
  /* True if this link was retrieved from /artists/{id}/albums with an "album_group" response of "appears_on". */
  `is_appears_on` integer NOT NULL,
  /* True if this link is based on the actual artists of each track in the album. Only exists if the link is not explicit (above). */
  `is_implicit_appears_on` integer NOT NULL,

  /* If neither is_appears_on or is_implicit_appears_on, the index of album.data.artists[ ] this was retrieved from. */

  `index_in_album` integer,
  FOREIGN KEY (`artist_rowid`) REFERENCES `artists`(`rowid`),
  FOREIGN KEY (`album_rowid`) REFERENCES `albums`(`rowid`)
  );
/* combinations of available markets in a separate table to save space */
CREATE TABLE `available_markets` (
  `rowid` integer PRIMARY KEY NOT NULL,
  /* comma separated ISO 3166-1 alpha-2 country codes. */
  `available_markets` text NOT NULL
  );
/* /albums/{id} - "Get Spotify catalog information for a single album." */
CREATE TABLE `albums` (
  `rowid` integer PRIMARY KEY NOT NULL,
  /* The original Spotify base62 ID. */
  `id` text NOT NULL,
  /* When the item was fetched (unixepoch ms). */
  `fetched_at` integer NOT NULL,
  /* "The name of the album. In case of an album takedown, the value may be an empty string." */
  `name` text NOT NULL,
  /* 'The type of the album. Allowed values: "album", "single", "compilation"' */
  `album_type` text NOT NULL,
  /* available markets as an index into the available_markets table to save space. - "The markets in which the album is available: ISO 3166-1 alpha-2 country codes. NOTE: an album is considered available in a market when at least 1 of its tracks is available in that market." */
  `available_markets_rowid` integer NOT NULL,
  /* external_id.upc - Universal Product Code */
  `external_id_upc` text,
  /* external_id.amgid - undocumented - AMG MUSIC GROUP Internal ID */
  "external_id_amgid" text,
  /* "The copyright" */
  `copyright_c` text,
  /* "The sound recording (performance) copyright." */
  `copyright_p` text,
  /* "The label associated with the album." */
  `label` text NOT NULL,
  /* "The popularity of the album. The value will be between 0 and 100, with 100 being the most popular." */
  `popularity` integer NOT NULL,
  /* "The date the album was first released." */
  `release_date` text NOT NULL,
  /* 'The precision with which release_date value is known. Allowed values: "year", "month", "day"' */
  `release_date_precision` text NOT NULL,
  /* tracks.total */
  `total_tracks` integer NOT NULL,
  FOREIGN KEY (`available_markets_rowid`) REFERENCES `available_markets`(`rowid`)
  );


/* album.images[ ] - "The cover art for the album in various sizes, widest first." */

CREATE TABLE "album_images" (
  `album_rowid` integer NOT NULL,
  `width` integer NOT NULL,
  `height` integer NOT NULL,
  `url` text NOT NULL,
  FOREIGN KEY (`album_rowid`) REFERENCES `albums`(`rowid`)
  );
/* /tracks/{id} */
CREATE TABLE `tracks` (
  `rowid` integer PRIMARY KEY NOT NULL,
  /* The original Spotify base62 ID. */
  `id` text NOT NULL,
  /* When the item was fetched (unixepoch ms). */
  `fetched_at` integer NOT NULL,
  /* "The name of the track." */
  `name` text NOT NULL,
  /* "A link to a 30 second preview (MP3 format) of the track. Can be null" */
  `preview_url` text,
  `album_rowid` integer NOT NULL,
  /* "The number of the track. If an album has several discs, the track number is the number on the specified disc." */
  `track_number` integer NOT NULL,
  /* http://en.wikipedia.org/wiki/International\_Standard\_Recording\_Code */
  `external_id_isrc` text,
  `external_id_ean` text,
  `external_id_upc` text,
  /* "The popularity of the track. The value will be between 0 and 100, with 100 being the most popular.
  The popularity of a track is a value between 0 and 100, with 100 being the most popular. The popularity is calculated by algorithm and is based, in the most part, on the total number of plays the track has had and how recent those plays are.
  Generally speaking, songs that are being played a lot now will have a higher popularity than songs that were played a lot in the past. Duplicate tracks (e.g. the same track from a single and an album) are rated independently. Artist and album popularity is derived mathematically from track popularity. Note: the popularity value may lag actual popularity by a few days: the value is not updated in real time." */
  `popularity` integer NOT NULL,
  /* A reference into the available_markets table. - "A list of the countries in which the track can be played, identified by their ISO 3166-1 alpha-2 code." */
  `available_markets_rowid` integer NOT NULL,
  /* "The disc number (usually 1 unless the album consists of more than one disc)." */
  `disc_number` integer NOT NULL,
  /* "The track length in milliseconds." */
  `duration_ms` integer NOT NULL,
  /* "Whether or not the track has explicit lyrics ( true = yes it does; false = no it does not OR unknown)." */
  `explicit` integer NOT NULL,
  FOREIGN KEY (`available_markets_rowid`) REFERENCES `available_markets`(`rowid`)
  );
/* "The artists who performed the track." */
CREATE TABLE `track_artists` (
  `track_rowid` integer NOT NULL,
  `artist_rowid` integer NOT NULL,
  FOREIGN KEY (`track_rowid`) REFERENCES `tracks`(`rowid`),
  FOREIGN KEY (`artist_rowid`) REFERENCES `artists`(`rowid`)
  );

CREATE INDEX `artist_genres_artist_id` ON `artist_genres` (`artist_rowid`);
CREATE INDEX `artist_genres_genre` ON `artist_genres` (`genre`);
CREATE INDEX `artist_images_artist_id` ON `artist_images` (`artist_rowid`);
CREATE UNIQUE INDEX `artists_id_unique` ON `artists` (`id`);
CREATE INDEX `artists_name` ON `artists` (`name`);
CREATE INDEX `artists_popularity` ON `artists` (`popularity`);
CREATE INDEX `artists_followers` ON `artists` (`followers_total`);
CREATE INDEX `artist_album_artist_id` ON "artist_albums" (`artist_rowid`);
CREATE INDEX `artist_album_album_id` ON "artist_albums" (`album_rowid`);
CREATE UNIQUE INDEX `albums_id_unique` ON `albums` (`id`);
CREATE INDEX `album_name` ON `albums` (`name`);
CREATE INDEX `album_popularity` ON `albums` (`popularity`);
CREATE UNIQUE INDEX `available_markets_available_markets_unique` ON `available_markets` (`available_markets`);
CREATE INDEX `track_artists_artist_id` ON `track_artists` (`artist_rowid`);
CREATE INDEX `track_artists_track_id` ON `track_artists` (`track_rowid`);
CREATE UNIQUE INDEX `tracks_id_unique` ON `tracks` (`id`);
CREATE INDEX `tracks_popularity` ON `tracks` (`popularity`);
CREATE INDEX `tracks_album` ON `tracks` (`album_rowid`);
CREATE INDEX `album_images_album_id` ON `album_images` (`album_rowid`);
CREATE INDEX tracks_isrc on tracks(external_id_isrc);
```
```sql
/* /audio-features/{id} "Get audio feature information for a single track identified by its unique Spotify ID." */
CREATE TABLE `track_audio_features` (
  `rowid` integer PRIMARY KEY NOT NULL,
  `track_id` text NOT NULL,
  `fetched_at` integer NOT NULL,
  /* true if the API returned null for the whole request */
  `null_response` integer NOT NULL,
  /* "The duration of the track in milliseconds." */
  `duration_ms` integer,
  /* 'An estimated time signature. The time signature (meter) is a notational convention to specify how many beats are in each bar (or measure). The time signature ranges from 3 to 7 indicating time signatures of "3/4", to "7/4".' */
  `time_signature` integer,
  /* "The overall estimated tempo of a track in beats per minute (BPM). In musical terminology, tempo is the speed or pace of a given piece and derives directly from the average beat duration." */
  `tempo` integer,
  /* "The key the track is in. Integers map to pitches using standard Pitch Class notation. E.g. 0 = C, 1 = C♯/D♭, 2 = D, and so on. If no key was detected, the value is -1." */
  `key` integer,
  /* "Mode indicates the modality (major or minor) of a track, the type of scale from which its melodic content is derived. Major is represented by 1 and minor is 0." */
  `mode` integer,
  /* "Danceability describes how suitable a track is for dancing based on a combination of musical elements including tempo, rhythm stability, beat strength, and overall regularity. A value of 0.0 is least danceable and 1.0 is most danceable." */
  `danceability` real,
  /* "Energy is a measure from 0.0 to 1.0 and represents a perceptual measure of intensity and activity. Typically, energetic tracks feel fast, loud, and noisy. For example, death metal has high energy, while a Bach prelude scores low on the scale. Perceptual features contributing to this attribute include dynamic range, perceived loudness, timbre, onset rate, and general entropy." */
  `energy` real,
  /* "The overall loudness of a track in decibels (dB). Loudness values are averaged across the entire track and are useful for comparing relative loudness of tracks. Loudness is the quality of a sound that is the primary psychological correlate of physical strength (amplitude). Values typically range between -60 and 0 db." */
  `loudness` real,
  /* "Speechiness detects the presence of spoken words in a track. The more exclusively speech-like the recording (e.g. talk show, audio book, poetry), the closer to 1.0 the attribute value. Values above 0.66 describe tracks that are probably made entirely of spoken words. Values between 0.33 and 0.66 describe tracks that may contain both music and speech, either in sections or layered, including such cases as rap music. Values below 0.33 most likely represent music and other non-speech-like tracks." */
  `speechiness` real,
  /* "A confidence measure from 0.0 to 1.0 of whether the track is acoustic. 1.0 represents high confidence the track is acoustic." */
  `acousticness` real,
  /* "Predicts whether a track contains no vocals. "Ooh" and "aah" sounds are treated as instrumental in this context. Rap or spoken word tracks are clearly "vocal". The closer the instrumentalness value is to 1.0, the greater likelihood the track contains no vocal content. Values above 0.5 are intended to represent instrumental tracks, but confidence is higher as the value approaches 1.0." */
  `instrumentalness` real,
  /* "Detects the presence of an audience in the recording. Higher liveness values represent an increased probability that the track was performed live. A value above 0.8 provides strong likelihood that the track is live." */
  `liveness` real,
  /* "A measure from 0.0 to 1.0 describing the musical positiveness conveyed by a track. Tracks with high valence sound more positive (e.g. happy, cheerful, euphoric), while tracks with low valence sound more negative (e.g. sad, depressed, angry)." */
  `valence` real
  );
CREATE UNIQUE INDEX `track_audio_features_track_id_unique` ON `track_audio_features` (`track_id`);

```

*   数据公开一星期后，该黑客组织官网已被DMCA下架，相关资源也已被删除
    
*   数据仅用于学习研究用途，绝对禁止商业使用！
    

**数据清洗**

*   原始数据被保存成sqlite3文件，主数据库文件中共有256M条记录，体积约为120GB
    
*   TIP：超大体积的sqlite使用polars操作，不要用duckdb，已踩坑
    
*   由于2025年以后AI歌曲泛滥，大量未被播放过的AI歌曲占总数的一半以上。通过筛选popularity>=1的数据，完成大部分的数据瘦身：256M -> 45M
    
*   接下来需要合并数据表，思路是：获取瘦身后45M数据的track\_id，从其它表中拉取相同track\_id的数据，保留需要的字段，其它数据删除，然后合并到一个总表。同样的，跨sqlite文件操作，瘦身音频特征表
    
*   最后只保留剩两个表：一个是结合track\_id、artist\_id、album\_id等字段的基本数据表，另一个是结合track\_id、key、mode、tempo等字段的音频分析表
    
*   数据按popularity排列，热度越高item越靠前
    
*   将artist\_id(str)额外编码成artist\_idx(int)
    
*   将artist\_genres取第一个genre，额外编码成artist\_genre\_idx(int)，该字段在后续的训练中非常有用
    
*   读取这两个sqlite表，合并字段，转成HuggingFace Dataset。最终保留字段：
    

![image (3).png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/4016da30-35bf-4346-a788-3c03646fc5bf.png)

```markdown
Track info:
- track_id (string): Spotify base62 track id
- track_name (string)
- isrc (string): International Standard Recording Code (ISRC). May be empty / missing.
- popularity (float): track popularity normalized to 0..1, rounded to 2 decimals.
  Original Spotify scale is 0..100 (we apply popularity / 100.0).
- explicit (int): 0 = non-explicit, 1 = explicit.
  Derived from tracks.explicit (any non-zero value is treated as explicit).

Artist info (primary artist):
- artist_idx (int): popularity-based index (see “Indexes”)
- artist_id (string): updated (redirect-applied) Spotify base62 artist id
- artist_name (string): may be empty for unknown artists
- artist_popularity (float): normalized to 0..1, rounded to 2 decimals
- artist_genres (string): comma-separated genres (delimiter is ", "), may be empty.
  After artist_id redirects: if the NEW artist_id has genres, we use those; otherwise we fall back to the old artist’s genres.
- artist_genre_idx (int): frequency-based index (see “Indexes”)

Album info:
- album_id (string)
- album_name (string)
- release_year (int): extracted from album.release_date (first 4 chars); 0 if missing/unknown/invalid.

Track audio features:
- duration (int): seconds, computed as floor(duration_ms / 1000).
- time_signature (int): estimated time signature. Typically 3..7.
- tempo (int): estimated tempo in BPM, rounded to nearest integer.
- key (int): estimated key as pitch class.
  Mapping: 0=C, 1=C♯/D♭, 2=D, ..., 11=B. -1 means “unknown / not detected”.
- mode (int): modality. 1=major, 0=minor.
- danceability (float): 0.0..1.0 (rounded to 4 decimals)
- energy (float): 0.0..1.0 (rounded to 4 decimals)
- loudness (float): overall loudness in dB (typically -60..0, rounded to 4 decimals)
- speechiness (float): 0.0..1.0 (rounded to 4 decimals)
- acousticness (float): 0.0..1.0 (rounded to 4 decimals)
- instrumentalness (float): 0.0..1.0 (rounded to 4 decimals)
- liveness (float): 0.0..1.0 (rounded to 4 decimals)
- valence (float): 0.0..1.0 (rounded to 4 decimals)
```

**数据分析**

*   热度分布（log指数）
    

![sel_08b_songs_by_popularity_log.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/5f3ff7a7-b647-48d7-8b2c-1682aba62c55.png)

*   流派分布（共741类）
    

![sel_02_top_genres.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/0edb87ba-2bd1-42bb-85b9-1e7b8ad6f9ff.png)

*   调式分布
    

![sel_af_25_mode_by_key.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/b3b1164c-0df6-4992-b7bd-1654fe8fce20.png)

*   二维热力分布
    

![sel_af_23c_feature_heatmap_grid.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/f96ff9f7-f222-4c12-bb36-45c0a4a7087b.png)

## Embeat v0.1：Triplet Loss

**任务分析**

*   Embeat系统主要任务：用户选择一首歌，得到这首歌的相关推荐，主要是风格类似
    
*   在不引入模型，纯靠数据库实现的话，这个任务大概可以拆解成：用户选择一首歌，输入track\_id，从数据库中获取到这首歌的key、mode、valance等音频high-level特征数据，然后通过SQL命令WHERE、SELECT等，筛选比如mode相同、key接近等条件，然后将筛选后的数据整理，返回给前端。但这引入了一个很严重的问题，面对45M的数据，SQL数据库使用B-Tree索引，检索压力会很大，保守估计一次请求要至少5~10秒的查询时间。而且这种查询还会误伤，条件过于苛刻的话甚至会将比如tempo离阈值只差1BPM，其它条件都完美符合，然后拒之门外的情况
    
*   这种情况下，最好的方法是引入向量数据库，将key、mode这些数值特征通过某个embedding模型转成对应向量，向量间的距离就是歌与歌之间风格的接近程度。使用这套流程，一方面能大大提升检索速度，响应基本是毫秒级的，另一方面向量是球形检索，检索条件是浮动不僵硬的。而且可以结合上述方案，比如先筛选具有相同genre的items，然后在这些items中做余弦相似度检索，可玩性更高
    
*   因此，这个模型的输入是各种音频特征文本，包含离散值与连续值，输出是一个embedding。抽象来说，这个模型除了能根据这些特征区分歌曲风格，同时也是个相似度打分器
    

**输入输出**

*   离散变量：
    

```markdown
key：调性，比如C调、A调，数值为0~12
mode：调式，小调、大调，数值为0、1
tempo_idx：由BPM值转成4个桶，BPM<80为慢速1，80~120为中速2，120~150为快速3，150以上为非常快4
time_signature：节拍，如四三拍、四四拍，数值为3~7
```

*   连续变量：（数值为0.0~1.0）
    

```markdown
danceability (可舞性)：基于节拍稳定性、强度
energy (能量)：强度、响度、动态范围
valence (情绪效价)：音乐的积极/消极程度（快乐 vs 悲伤）
instrumentalness (器乐性)：人声占比
acousticness (原声性)：是否使用电子合成器
liveness (现场感)：是否为现场录音
speechiness (语音度)：是否为说话语音（如朗诵）
```

*   输出：参考Spotify之前用40维向量，加上这些数值之间相互联系，64维向量足以
    

**Dataset**

*   从HF Datasets中抽数据，并做一些标准化转换，预留0=<UNK>的情况
    
*   key、mode、tempo\_idx：向后偏移1位
    
*   time\_signature：不变，因为不存在0拍子的情况，0拍子即为<UNK>
    
*   所有dense变量：转为-1.0~1.0分布，其中0.0=<UNK>。之后可以通过概率dropout这个dense tower来增强模型的鲁棒性
    

**Sampler（Triplet Loss版）**

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/93b240d7-96ea-488b-a9bb-8f2d29e54935.png)

*   推荐算法的核心部分，决定了模型是否能注意到歌曲之间的联系
    
*   在一个batch中，随机选取一个Anchor作为中心，然后根据一些正约束规则选取Positive，根据负约束规则选取Negative，最终使distance(A, P) + margin >= distance(A, N)
    
*   正约束规则：
    

\- 来自相同的album或相同的genre、相同的mode、相同的tempo\_idx、接近的key

\- 接近的key：五度圈距离不超过1，或半音圈距离不超过1

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/f90720eb-fe8d-485f-80cf-23b9f3752d44.png)![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/9162b743-8bb7-45f0-857b-787a03d56e93.png)

*   负约束基础规则：
    

1.  不同 track（idx 不同）
    
2.  若双方 \`artist\_idx>0\` 则 artist 不同（避免同艺人泄漏）
    
3.  若双方 \`isrc\` 非空，则 isrc 不同（避免同曲不同版本/重复）
    
4.  若双方 \`album\_id\` 非空，则 album 不同
    

*   负约束分级规则：
    

1.  key 强约束（所有负样本都必须满足）：
    
      - 五度圈距离 \`>= neg\_key\_fifths\_dist\_min\`
    
      - 且半音圈距离 \`>= neg\_key\_chromatic\_dist\_min\`
    
2.  hard negative：  
    
      - \`mode\` 相同、\`tempo\_idx\` 相同  
    
      - \`valence\_diff\` 或 \`energy\_diff\` 足够大（阈值由 \`neg\_hard\_\*\_diff\_min\` 控制）  
    
3.  semi-hard negative（同 mode）：  
    

  - 若 \`tempo\_idx\` 差异 >= \`neg\_semi\_tempo\_idx\_diff\_min\` 则更偏“半难”（key 已满足强约束）  

\- easy negative：不同 genre 或不同 mode（key 已满足强约束）

*   最终过滤
    

\- 在满足约束的候选中，仍然用 dense 的 L2 距离选“最接近的”（harder negative）

**Dataloader**

*   从Dataset中取一条条标准化后的数据，经过Sampler筛选，打包，转成一个个tensor batch
    
*   Dataloader shape：
    

返回{anchor, positive, negative}，其中anchor\[B\]\[\] = SpotifyTracksDataset\[idx\]

**Model**

```python
class EmbeatMLP(nn.Module):
    def __init__(self, config: EmbeatMLPConfig):
        super().__init__()
        self.config = config
        # Data embeddings: 13 + 3 + 6 + 5 = 27 -> 8 + 4 + 4 + 4 = 20
        self.key_embedding = nn.Embedding(int(config.key_vocab_size), int(config.key_emb_dim))
        self.mode_embedding = nn.Embedding(int(config.mode_vocab_size), int(config.mode_emb_dim))
        self.time_signature_embedding = nn.Embedding(
            int(config.time_signature_vocab_size), int(config.time_signature_emb_dim)
        )
        self.tempo_embedding = nn.Embedding(int(config.tempo_vocab_size), int(config.tempo_emb_dim))
        # Discrete tower: 20 -> 64 -> 64
        discrete_input_dim = int(self.config.key_emb_dim + self.config.mode_emb_dim + self.config.time_signature_emb_dim + self.config.tempo_emb_dim)
        self.discrete_tower = nn.Sequential(
            nn.Linear(int(discrete_input_dim), int(config.discrete_output_dim)),
            nn.BatchNorm1d(int(config.discrete_output_dim)),
            nn.PReLU(),
            nn.Linear(int(config.discrete_output_dim), int(config.discrete_output_dim)),
            nn.BatchNorm1d(int(config.discrete_output_dim)),
            nn.PReLU(),
        )
        # Acoustic tower: 7 -> 64 -> 64
        self.acoustic_tower = nn.Sequential(
            nn.Linear(int(config.acoustic_input_dim), int(config.acoustic_output_dim)),
            nn.BatchNorm1d(int(config.acoustic_output_dim)),
            nn.PReLU(),
            nn.Linear(int(config.acoustic_output_dim), int(config.acoustic_output_dim)),
            nn.BatchNorm1d(int(config.acoustic_output_dim)),
            nn.PReLU(),
        )
        # fusion_dim: 64 + 64 = 128
        fusion_dim = int(self.config.acoustic_output_dim + self.config.discrete_output_dim)
        hidden_0, hidden_1 = config.backbone_hidden_dims
        # Backbone: 128 -> 256 -> 128 -> 64
        self.backbone = nn.Sequential(
            nn.Linear(int(fusion_dim), int(hidden_0)),
            nn.BatchNorm1d(int(hidden_0)),
            nn.PReLU(),
            nn.Dropout(p=float(config.backbone_dropout_p)),
            nn.Linear(int(hidden_0), int(hidden_1)),
            nn.BatchNorm1d(int(hidden_1)),
            nn.PReLU(),
            nn.Dropout(p=float(config.backbone_dropout_p)),
            nn.Linear(int(hidden_1), int(config.embedding_dim)),
        )
```

*   key：13 -> 8
    
*   mode：3 -> 4
    
*   time\_signature：6 -> 4
    
*   tempo\_idx：5 -> 4
    
*   discrete：8 + 4 + 4 + 4 =20 -> 64
    
*   dense：7 -> 64
    
*   discrete + dense = 128 -> 256 -> 128 -> 64
    

**Loss**

*   Triplet margin：relu(d(ap) - d(an) + margin)
    
*   popularity加权：popularity经过0.5指数变换后，分别加到batch中每个样本的loss上，使高权重关注度更高
    
*   统计输出：loss、grad\_norm、ap\_dist、an\_dist、active\_triplet、weight\_mean
    

![d122e36972695799d7f68380bce342d2.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/83148b47-ebb7-446b-a764-01e8f2e6b410.png)

**效果演示**

*   动漫歌曲
    

![1770779963296_D83DB2BF-72B6-4964-96AA-9446174160D7.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/f8ceec4c-b374-42dd-af63-bfdb05a12d6b.png)

*   小众歌曲
    

![9a3a2a4fb2e94ffc98faf2bdac705a7e.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/e8b7cba3-e94c-4310-8734-2ab20e1cf1b1.png)

*   周杰伦vs动漫歌曲
    

![1770780165828_8EBB7E50-C079-4f6b-83F1-5EBEB734F555.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/3629677e-f417-4098-a44b-850a0f8a6585.png)

## Embeat v0.2：Masked InfoNCE

**v0.1存在的问题**

*   在Triplet loss眼中，对于每个anchor，只有一个既定的positive与一个nagative。但实际情况并非如此，一首种子歌曲可以有多个positive，同时也能有多个negative
    
*   Triplet loss这种非黑即白的逻辑会导致模型很快成为一个规则过滤器，一旦符合positive的规则，给高分，否则均为低分，几乎没有中间地带。而实际经验告诉我们，这样的模型泛化性不强，不能很好地反映歌曲高维声学特征间的内部规律
    
*   Triplet loss的margin参数很难调整，调高了所有样本非黑即白，缺乏泛化能力。调低了模型偷懒，分不清长得像的负样本。因此Triplet loss经常会配合大量负样本挖掘规则（难负样本挖掘），让负样本与正样本尽可能接近，强制模型在空间上区分正负样本
    
*   为了让Triplet loss具备一定泛化能力，我们经常会开更高的dropout，让模型在前向传播中丢弃一部分参数信息，比如我设置的0.2其实已经很高了。但这并不能解决根本问题，因为当前推荐场景就不适合做一个positive与一个negative的挖掘，必须找到一种”一对多“的解决方案
    
    **InfoNCE与变体**
    
*   InfoNCE很好地解决了这个问题。在标准的InfoNCE模型中，对于一个anchor，它有一个既定的positive，而batch中除了anchor与positive外的其它样本全是negative。这样在一个batch中（假定batch\_size=4096），会产生4096\*4096的分数矩阵，每一行记录着一个anchor的最终分数。假如anchor\_index=0，positive\_index=2，第一行的分数可能是：\[1.0, 0.1, 0.2, 0.9, 0.1...\]
    
*   计算loss也很简单，排除掉anchor自身的1.0，把positive分数作为分子，所有negative分数相加作为分母去计算。具体计算公式如下：
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/9401d6ce-e284-4e4c-b16c-c5aa5a535612.png)

*   这里可能出现一个问题，在一个batch中，样本不是anchor、positive，难道就一定是negative吗？由于negative的数量非常多，指数处理后相加，自身分数会被淡化，降低假负样本对最终分数的影响
    
*   进阶思考：对于一个anchor，我们可能有多个positive。而且除anchor外的样本直觉上应该分为三类：正样本、负样本、非正非负样本。因此SupCon出现了。我们通过正样本筛选规则从batch中取出多个正样本，又通过负样本筛选规则取出多个负样本，剩下的样本就是非正非负样本
    
*   将正样本分数相加后取平均（除以正样本数量）；又通过mask的方式，将非正非负样本从公式的分母部分取出，即抛弃掉这部分数据，它既不是分子也不是分母。这样就实现了我们最终想要的效果：多个正样本、多个负样本、非正非负样本不要影响计算
    

**Sampler**

*   目标：在一个batch中，挖掘出正样本与负样本
    
*   正样本规则：
    

1.  必须遵守：相同的mode，相同的time\_sinature，相近的tempo，相近的key，另外instrumentalness、speechiness不能相差太大
    
2.  相近的key：五度圈距离±2，或半音圈距离±1，或关系大小调
    
3.  声学特征相似度：anchor与positive会有接近的valence（T0）、energy（T0）、danceability（T1）、acousticness（T1）、liveness（T2）。计算anchor与positive的连续声学特征L2距离，决定其是否能被判定为positive。L2计算思路：distance = sqrt(weight\_vector \* (anchor\_vector - candidate\_vector) ^ 2)
    
4.  额外奖励：如果anchor与positive有相同的genre、album、artist，能得到一定的分数奖励，取负值加到上述distance，即拉近candidate与anchor的距离
    
5.  公式原理上可以取多个正样本，实际工程中最后只取一个分数最高的正样本
    

*   负样本规则：
    

1.  在batch中，默认情况下，所有样本不是anchor、positive，就是negative
    
2.  除非样本来自相同的专辑，或拥有相同的ISRC，这部分mask掉，不参与分母计算
    

```python
class PairSamplerConfig:
    # Positive bucket A rate: sample from same album, fallback to bucket B if miss
    pos_album_ratio: float = 0.99
    # Positive bucket B rate: sample by near acoustic similarity, fallback to bucket A if miss
    pos_genre_acoustic_ratio: float = 0.01
    # Positive dense threshold: valence difference of anchor and positive should not be too big
    pos_valence_diff_max: float = 0.20
    # Positive dense threshold: energy difference of anchor and positive should not be too big
    pos_energy_diff_max: float = 0.20
    # Positive dense threshold: danceability difference of anchor and positive should not be too big
    pos_danceability_diff_max: float = 0.30
    # Positive dense threshold: acousticness difference of anchor and positive should not be too big
    pos_acousticness_diff_max: float = 0.30
    # Positive dense threshold: liveness difference of anchor and positive should not be too big
    pos_liveness_diff_max: float = 0.20
    # Positive dense threshold: speechiness difference of anchor and positive should not be too big
    pos_speechiness_diff_max: float = 0.05
    # Positive dense threshold: L2 distance of anchor and positive should not be too far
    pos_dense_weighted_l2_max: float = 0.30
    # Positive dense weight: valence weight in L2 distance calculation
    pos_dense_weight_valence: float = 1.0
    # Positive dense weight: energy weight in L2 distance calculation
    pos_dense_weight_energy: float = 1.0
    # Positive dense weight: danceability weight in L2 distance calculation
    pos_dense_weight_danceability: float = 1.0
    # Positive dense weight: acousticness weight in L2 distance calculation
    pos_dense_weight_acousticness: float = 0.5
    # Positive dense weight: liveness weight in L2 distance calculation
    pos_dense_weight_liveness: float = 0.5
    # Positive dense weight: speechiness weight in L2 distance calculation
    pos_dense_weight_speechiness: float = 1.0
    # Discrete hard constraints: mode of anchor and positive should be the same (relative major/minor will be excluded)
    require_mode_match_for_positive: bool = True
    # Discrete hard constraints: key of anchor and positive should be close (circle-of-fifths distance, chromatic-circle distance or relative major/minor)
    require_key_close_for_positive: bool = True
    # Discrete hard constraints: tempo of anchor and positive should be close
    require_tempo_close_for_positive: bool = True
    # Discrete hard constraints: time signature of anchor and positive should be the same
    require_time_signature_match_for_positive: bool = True
    # Discrete hard constraints: artist genre of anchor and positive should be the same
    require_genre_match_for_positive: bool = False
    # Tempo threshold: abs(anchor_tempo_idx - positive_tempo_idx) <= pos_tempo_idx_diff_max
    pos_tempo_idx_diff_max: int = 1
    # Circle-of-fifths distance threshold
    pos_key_fifths_dist_max: int = 2
    # chromatic-circle distance threshold
    pos_key_chromatic_dist_max: int = 1
    # Positive filter: skip positive if positive_genre is "Unknown"
    pos_require_known_genre: bool = True
    # Anchor filter: skip anchor if anchor_genre is "Unknown" (set to True if require_genre_match_for_positive == True)
    anchor_require_known_genre: bool = True
    # Negative loss mask: anchor and negative should not be from the same album
    neg_exclude_same_album: bool = True
    # Negative loss mask: anchor and negative should not have the same ISRC
    neg_exclude_same_isrc: bool = True
    # Positive sampling bias: higher value means prefer higher popularity sample, applies to positive candidate score
    pos_popularity_power: float = 0.5
    # Positive sampling bias: minimum popularity weight
    pos_popularity_min_weight: float = 0.10
    # Positive sampling bias: maximum popularity weight
    pos_popularity_max_weight: float = 1.00
    # Positive candidates ranking bias: anchor and positive are from a same album
    score_bonus_same_album: float = 0.08
    # Positive candidates ranking bias: anchor and positive are from a same artist
    score_bonus_same_artist: float = 0.04
    # Positive candidates ranking bias: anchor and positive have a same genre
    score_bonus_same_genre: float = 0.02
    # Positive candidates ranking bias: anchor and positive have a same mode
    score_bonus_mode_match: float = 0.00
    # Positive candidates ranking bias: anchor and positive have a close key
    score_bonus_key_close: float = 0.00
    # Positive candidates ranking bias: anchor and positive have a close tempo_idx
    score_bonus_tempo_close: float = 0.00
    # Positive candidates ranking bias: anchor and positive have a same time signature
    score_bonus_time_signature_match: float = 0.00
    # Global filter: speechiness_raw >= threshold will be excluded, to avoid talk-only audio
    speechiness_max_exclusive_raw: float = 0.20
    # Cache items per genre
    cache_per_genre: int = 4096
    # Cache items per album
    cache_per_album: int = 128
    # Limit number of album ids in cache (avoid memory leak)
    max_album_cache_keys: int = 10000
    # Try time in one of positive buckets (higher value consumes more time but lower anchor_drop_frac)
    max_positive_tries: int = 128
    # Global random seed
    random_seed: int = 616
```

**Model**

*   discrete + dense = 128 -> 256 -> **256** -> 64（Backbone倒数第二层维度从128改成256）
    

**Trainer**

*   batch\_size=4096，dataset\_len=45059660 -> 10000000
    
*   训练跑200 steps即可，约十分之一个epoch
    
*   学习率调度器使用恒定学习率，lr=1e-4，weight\_decay=1e-3
    
*   梯度裁剪=1.0，训练稳定后基本不会触发
    
*   关闭所有dropout
    
*   InfoNCE温度值tau设为0.05，重要
    
*   根据正负样本相似度的评估结果，最后取step=70的checkpoint，并不是loss越低越好
    
*   因为模型参数量很少，加上任务简单，还没跑完一个epoch就已经基本拟合，属于正常现象
    

```python
step: 10 loss: 7.013882 lr: 0.0001 grad_norm: 4.9073 anchor_drop_frac: 0.269 pos_per_anchor: 96.736 neg_same_genre_frac: 0.001 batch_masked_frac: 0.069 step_per_sec: 0.06
step: 20 loss: 6.1141 lr: 0.0001 grad_norm: 3.9595 anchor_drop_frac: 0.456 pos_per_anchor: 34.923 neg_same_genre_frac: 0.001 batch_masked_frac: 0.103 step_per_sec: 0.02
step: 30 loss: 5.631929 lr: 0.0001 grad_norm: 1.7332 anchor_drop_frac: 0.442 pos_per_anchor: 34.743 neg_same_genre_frac: 0.001 batch_masked_frac: 0.107 step_per_sec: 0.04
step: 40 loss: 5.375176 lr: 0.0001 grad_norm: 1.1491 anchor_drop_frac: 0.419 pos_per_anchor: 34.851 neg_same_genre_frac: 0.001 batch_masked_frac: 0.111 step_per_sec: 0.03
step: 50 loss: 5.246718 lr: 0.0001 grad_norm: 0.8457 anchor_drop_frac: 0.428 pos_per_anchor: 34.026 neg_same_genre_frac: 0.001 batch_masked_frac: 0.108 step_per_sec: 0.03
step: 60 loss: 5.140089 lr: 0.0001 grad_norm: 0.6737 anchor_drop_frac: 0.426 pos_per_anchor: 33.726 neg_same_genre_frac: 0.001 batch_masked_frac: 0.113 step_per_sec: 0.03
step: 70 loss: 5.063671 lr: 0.0001 grad_norm: 0.6964 anchor_drop_frac: 0.428 pos_per_anchor: 33.639 neg_same_genre_frac: 0.001 batch_masked_frac: 0.104 step_per_sec: 0.03
step: 80 loss: 5.017426 lr: 0.0001 grad_norm: 0.6436 anchor_drop_frac: 0.416 pos_per_anchor: 34.18 neg_same_genre_frac: 0.001 batch_masked_frac: 0.116 step_per_sec: 0.03
step: 90 loss: 4.955722 lr: 0.0001 grad_norm: 0.6324 anchor_drop_frac: 0.404 pos_per_anchor: 33.867 neg_same_genre_frac: 0.001 batch_masked_frac: 0.111 step_per_sec: 0.03
step: 100 loss: 5.569067 lr: 0.0001 grad_norm: 0.4346 anchor_drop_frac: 0.17 pos_per_anchor: 96.831 neg_same_genre_frac: 0.001 batch_masked_frac: 0.075 step_per_sec: 0.09
step: 200 loss: 4.747747 lr: 0.0001 grad_norm: 0.5333 anchor_drop_frac: 0.393 pos_per_anchor: 32.798 neg_same_genre_frac: 0.001 batch_masked_frac: 0.106 step_per_sec: 0.03
step: 300 loss: 4.672141 lr: 0.0001 grad_norm: 0.4249 anchor_drop_frac: 0.397 pos_per_anchor: 32.192 neg_same_genre_frac: 0.001 batch_masked_frac: 0.113 step_per_sec: 0.02
step: 400 loss: 4.655772 lr: 0.0001 grad_norm: 0.3894 anchor_drop_frac: 0.399 pos_per_anchor: 32.641 neg_same_genre_frac: 0.001 batch_masked_frac: 0.113 step_per_sec: 0.02
step: 500 loss: 4.658092 lr: 0.0001 grad_norm: 0.4877 anchor_drop_frac: 0.396 pos_per_anchor: 32.981 neg_same_genre_frac: 0.001 batch_masked_frac: 0.113 step_per_sec: 0.02
```

**v1.1修复Bug**

*   20260626开源的那版代码发现一个bug，即离散特征tempo > 100BPM的信号被裁剪了，bug影响了v0.2~v1.0所有版本，不过问题不算大
    
*   20260906版已修复tempo问题，重跑训练，EmbeatMLP模型权重、Qdrant数据库已更新
    
*   最终EmbeatMLP权重选择step=330
    

**效果演示**

*   《Eleni Karaindrou - Karaindrou: Waltz By The River》 vs 《Eleni Karaindrou - Karaindrou: Dance Theme》：相似度0.96
    
    ![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/11533598-0614-4640-90ec-74d5c400ef24.png)
    
*   《周杰伦 - 晴天》 vs 《周杰伦 - 夜曲》：相似度0.73
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/1abb1b40-b6ef-4e93-97ce-8af044daacce.png)

*   《彩菜 - Last regrets》 vs 《Lia - 鳥の詩》：相似度0.67
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/3ffa0db3-cff6-4c60-b134-18c988d143cf.png)

*   《周杰伦 - 晴天》 vs 《Lia - 鳥の詩》：相似度0.64
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/f9fdc512-4c70-4a3d-b586-1c14b524088f.png)

*   《Oskar Schuster - Gizeh》 vs 《Gorillaz - Feel Good Inc.》：相似度0.36
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/065c35df-1b62-4e6e-8e79-9f773a1eeaba.png)

**分析**

*   通过将Triplet loss替换成InfoNCE变体，结果稳定且正确很多
    
*   解决了Triplet loss难以挖掘难负样本的问题
    
*   解决了Triplet loss容易退化成规则分类器的问题
    
*   InfoNCE只需关注正样本的挖掘，Sampler规则更好写
    
*   InfoNCE的模型也具备一定泛化能力，能正确处理介于positive与negative中间态的样本
    

## Embeat v0.3：Qdrant向量数据库

**目标**

*   最终目标是输入种子曲目，输出与种子歌曲风格相似的歌单，因此需要对接数据库
    
*   目前我们已经拥有能将high-level连续声学特征转换成embedding的模型EmbeatMLP。可以考虑使用向量数据库，将45M的歌曲经过模型全部计算embedding，然后保存到数据库中
    
*   常见的向量数据库有Milvus、Pinecone、Weaviate、Qdrant、Chroma‌。在数据库软件的选型上，考虑到要Python兼容、轻量、极速，最终选择Qdrant这款产品
    
*   数据来源是之前清洗的HuggingFace Dataset，输出是Qdrant向量数据库Collection
    

**数据库字段**

*   数据库是最后部署上线的部分，因此要严格控制文件体积跟内存占用。我们不需要所有的HF Dataset字段都保存到数据库中，只选择最重要的部分：
    

\-    track\_id (str)：用于检索定位种子曲目ID，以及返回内容也需要

\-    track\_name (str)：返回内容需要

\-    popularity (float)：用于Re-rank排序

\-    artist\_name (str)：返回内容需要

\-    artist\_idx (int)：“相同艺人”检索需要（参考v0.5多路召回章节）

\-    artist\_genres (str)：“相似风格”检索需要（参考v0.5多路召回章节）

\-    artist\_genre\_idx (int)：“相似风格”检索需要（参考v0.5多路召回章节）

\-    related\_artist\_idxs (List\[int\])：“相关艺人”检索需要（参考v0.5多路召回章节）

\-    album\_name (str)：返回内容需要

\-    isrc (str)：过滤多地区发行版本需要

**创建记录**

*   首先需要为每条记录创建一个独一无二的索引，在Qdrant中称为point\_id。point\_id只接受两种数据类型，int或UUID格式的str。这里我选择使用track\_id应用UUID5，转成point\_id。这样query时只要输入track\_id，就能毫秒级定位到数据行，等于为数据行生成一个极速的基于track\_id的索引
    
*   对HF Dataset数据做切块，每512条数据一个块，将dataset\_slice (List\[dict\])转成rows (Dict\[list\])
    
*   过滤掉track\_id为空或popularity < 0.01的行
    
*   从数据dict（energy=0.5, valence=0.4, etc）创建torch features
    
*   调用EmbeatMLP模型，batch推理，将audio features转成embedding，然后量化成uint8（节省75%的向量存储空间）并转成list（Qdrant只认list，不认torch或numpy数据）
    
*   集齐512条最终数据，upsert写进数据库
    

**创建Payload索引**

*   创建索引是为了更快的query速度，为一些可能传入作为query条件的字段创建索引，检索速度能达到百倍提升
    
*   创建索引唯一的缺点是占硬盘存储空间与部分内存空间，如果服务器资源吃紧可能会受影响，但利远大于弊
    
*   track\_id已通过point\_id创建索引，这里不需要重复创建
    
*   我为artist\_genre\_idx (int)、artist\_idx (int)、popularity (float)创建了必要索引
    
*   另外可自选为track\_name、artist\_name创建TextIndex索引，引入TokenizerType.WORD分词器，支持模糊检索。这部分索引永远保存在disk上，不占内存
    

**创建向量HNSW索引**

*   检索过程中需在向量空间计算余弦相似度匹配声学相似items，提前建立好向量的HNSW索引可以加快这部分检索速度
    
*   HNSW有两个比较关键的参数，一个是HnswConfigDiff.m，代表每个节点有多少条边。边数越多图连接越稠密，召回准确率越高，同时内存与硬盘的消耗也越多。边数越少，省内存、建图快，但图可能断路，召回下降。默认值是16，但因为我的模型维度64较小，因此我将这个值改为8，可以节省一半的HNSW初始化内存（实测约3GB）
    
*   另一个是HnswConfigDiff.ef\_construct，代表建图时的搜索宽度，即为每个新插入节点寻找邻居时维护的候选列表大小，数值越大图精度越高。经验值是100~200，因为选用比较小的m值，这里我将这个值改为200，比默认值更大
    
*   在建数据库时，我将optimizers\_config.indexing\_threshold的值设为0，意味着不建立HNSW索引，这样我能得到一份体积最小且最干净的数据库。先打包这份数据库
    
*   完成备份后，通过update\_collection方法更新indexing\_threshold的值为1，意味着只要遇到1个新item，马上为其建图，这样能为我的数据库充分建图，保证每个item都有HNSW索引。因为数据库后续大概率不会再新增item，所以indexing\_threshold可以设为1。如果后续仍有可能更新，最好改成默认值20000，但修改此值会再次触发优化器重新整理segments，CPU内存资源上升
    

**实测效果**

*   种子曲目：《周杰伦 - 晴天》
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/030ecbd6-2b6a-45dd-9e38-6cf509f56bef.png)

*   种子曲目：《梶浦由記 - Decretum》
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/5840dbc4-efb6-4c52-bf62-da31e0e6def4.png)

**分析**

*   通过部署Qdrant数据库，Embeat的应用雏形终于成型，通过输入query\_track\_id，得到相似结果
    
*   但这个推荐结果可以说是非常糟糕，推荐歌曲与种子歌曲间确实有接近的节拍、速度、甚至调式等等，它们的声学数据在数据库中，但听感却完全不一样。比如和声、配器、风格，可能都完全不同
    
*   好消息是，种子歌曲是人声曲，推荐歌曲返回人声曲；种子歌曲是纯音乐，推荐歌曲返回纯音乐。这说明instrumentalness参数起作用了
    

## Embeat v0.4：引入歌手流派

**目标**

*   只有声学特征相似并不能找到正确的推荐歌曲，因为key、mode、tempo等参数相同，得到的也可能是听感接近但风格截然不同的两首歌曲
    
*   这里我想到了Spotify早期做歌曲推荐时，大量使用NLP自然语言处理。从全网挖掘文本，比如通过音乐博客、乐评网站、维基百科等，建立artist与keyword的联系，即共现矩阵。如果人们在讨论两个音乐人时使用了高度重合的词汇，他们在特征空间中的距离就会被拉近，而这个词汇，大概率就是一种流派。这是一种还没处理的“伪流派”
    
*   之后就是量化这个流派。Spotify拥有energy、danceability、acousticness等上述提及的参数，通过计算这些艺人的作品的声学特征在空间中的分布，筛选掉一些噪声艺人。之后就能为艺人在共现矩阵与声学相似这两个集合中取交集。这个交集的质心，就是一个微流派
    
*   现在我有45M歌曲的声学embedding，但却没有流派信息。全网挖掘文本这个工作量非常大，由我一人完成也不太现实，因此考虑曲线救国，用网络上的数据填补这个缺口
    

**Every Noise at Once**

*   这个网站是音乐信息检索和推荐系统领域非常经典的一个可视化项目。它的创作者Glenn McDonald曾就职于Spotify，专业处理Data Alchemist
    
*   这个网站将艺人精准映射到几千个极端细分的微流派，每个艺人可能有多个微流派，且每个艺人在流派中包含权重大小信息，如下图所示
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/2ac5c1ff-5278-4d14-962f-562c91cc47d7.png)

*   同时，这个网站还有6000+种流派的二维地图与一维列表
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/6de6464e-3560-47ed-be74-960065871d1c.png)

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/0c72a60c-8a1e-40a0-b726-2fc2f88daf03.png)

**一阶段爬虫**

*   从1D-Map中获取genre\_list（pop, rap, rock...），保存成txt
    
*   从genre\_list中取genre\_str，拼接URL，再从具体的genre页面中抓取artist\_id、weight、map\_x、map\_y等信息，保存成json
    
*   可惜everynoise.com后台返回的不是json，而是静态网页，因此要使用bs4做前端爬虫处理
    
*   最后一共爬取6144个微流派
    
*   将genre\_str通过index转成genre\_idx，其中，idx的大小决定了流派的流行度（与1D-Map相同）。将genre\_idx=0分配给<UNK>，用于处理无流派的歌手
    
*   重新生成HF Dataset，将genre\_idx写进数据集中，同时重新生成Qdrant数据库
    

**二阶段爬虫**

*   EveryNoise网站上收录了绝大多数的艺人，但有些艺人因为过于小众，并未被EveryNoise收录。因此我借助“相似艺人”这个API，补充了这部分小众艺人的genre\_idx（相似艺人部分参考v0.5）
    
*   筛选了45M数据集中所有artist\_genre\_idx=0的艺人，一共有229.9万人。最后使用这种方法为92.7万小众艺人找到流派
    

**同艺人多流派处理**

*   一个艺人可能存在多个流派，我将这些流派使用artist\_genres (str)这个字段保存了流派名。而genre\_idx (int)只能存一个值，这样设计的初衷是，给这个艺人找到属于ta的最准确流派
    
*   起初的想法是，艺人在哪个流派的权重最高，那ta就属于哪个流派。这样上线后的系统发现一个严重问题，当这个流派实在太小众，而艺人的影响力又很大时，ta的权重就会变得特别高
    
*   比如歌手齐豫，她的微流派是“华语经典”、“华语流行”、“佛教音乐”。按常理讲，齐豫应该被划分为“华语经典”，可“华语经典”高手荟萃，而“佛教音乐”相比之下小众很多，所以齐豫在“佛教音乐”的权重就会变高。如果将齐豫归为这个类别，以后遇到齐豫的种子曲目，会推荐大量的佛教音乐
    
*   我爬取了EveryNoise网站的2D-Map，与每个流派的子页面，我能拿到这些数据：流派的x, y坐标（x代表energy，y代表accoustic，已归一化）、流派的权重、艺人的权重
    
*   我在这里引入一个引力模型，具体是这样工作的。首先计算多个流派的质心(Cx, Cy)：
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/6dba962f-c2dc-453f-842d-4d5ad373f27d.png)![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/42c76598-91d6-4ead-a4b0-51e28889e7ae.png)

\-    分子部分：总拉力，求和所有 x坐标 \* 流派权重

\-    分母部分：总质量，求和所有 流派权重

\-    质心：求得最后的 (Cx, Cy)

*   接下来计算艺人在每个流派中的得分（得分公式就是万有引力公式变体）：
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/dd38a410-b5fc-4b1b-8048-e5fa15757ea3.png)

\-    混合质量 = 艺人在流派中的权重 \* 流派本身的权重

\-    D = 每个流派坐标到质心的距离，使用欧式距离公式计算

\-    S = 距离缩放常数，即“舒适圈”半径，D超过S时就判定为“远离”了这个质心。S很大时对“异类”流派包容度更高

\-    a = 惩罚系数。当D超过S时，惩罚有多大。这里我将a设为2，参考万有引力公式

\-    分母加上系数1，是为了让Score不会因分母小于1接近0时暴增

*   这套模型的关键在于S值的确定。这里我用HDBSCAN，将空间中所有的二维平面点（流派）聚类，然后计算簇与簇之间的距离，取平均除以2，作为最终的S值。在HDBSCAN中有个关键参数叫min\_cluster\_size，意味着形成一个簇至少要多少个点。我将min\_cluster\_size从2慢慢调整到10，得到以下结果：
    

```python
Current min_cluster_size: 2
Cluster number: 961
Best scale value: 0.0077

Current min_cluster_size: 3
Cluster number: 516
Best scale value: 0.0106

Current min_cluster_size: 4
Cluster number: 314
Best scale value: 0.0138

Current min_cluster_size: 5
Cluster number: 224
Best scale value: 0.0155

Current min_cluster_size: 6
Cluster number: 3
Best scale value: 0.2554

Current min_cluster_size: 7
Cluster number: 3
Best scale value: 0.2573

Current min_cluster_size: 8
Cluster number: 2
Best scale value: 0.2592

Current min_cluster_size: 9
Cluster number: 2
Best scale value: 0.2620

Current min_cluster_size: 10
Cluster number: 2
Best scale value: 0.2626
```

\-    可以发现，min\_cluster\_size=5到min\_cluster\_size=6发生一个突变，簇数量从224爆减到3。EveryNoise有6000多个微流派。把6000个微流派聚合成224个大簇，符合我们对流派大类的理解数量，所以5这个值维系音乐流派区别的最小簇数量

\-    这意味着EveryNoise的流派排布极其致密，稍微走错地图的1.5%，就可能从“流行圈”转到“摇滚圈”或“民谣圈”。所以最终确定S=0.015

*   通过上述计算，我为每个艺人计算了一个属于ta最合适的流派，并记录成artist\_genre\_idx
    
*   顺带一提，通过这套模型计算齐豫的风格，最终将她分类为“华语经典”
    

**Qdrant端的处理**

*   将genre\_idx作为candidates的筛选条件，先筛选相同genre\_idx的candidates，然后应用query\_vector，按相似度从高到低排序，得到基于流派与声学模型过滤的推荐结果
    
*   这里的genre\_idx一般情况下会使用artist\_genre\_idx，即艺人的最终流派。但这里我引入另一套处理多流派的机制，来获取track\_genre\_idx。为什么要这样设计？如果用户输入齐豫的佛教音乐，而系统返回齐豫或张学友的经典音乐，这多少会有些割裂
    
*   track\_genre\_idx具体是这样设计的：
    

1.  首先将artist\_genres通过6144个genre的字典翻译成artist\_genre\_idxs (list)
    
2.  然后artist\_genre\_idxs作为Qdrant的搜索条件，找到符合这些流派且声学特征相近的所有歌曲candidates
    
3.  get\_track\_genre\_idx函数有两个输入：candidates (list), fallback\_idx (int)。其中candidates按声学特征从高到低排序，fallback\_idx等于artist\_genre\_idx或0
    
4.  计算candidates中每个流派的得分，流派分数 = 求和所有 (index ^ 0.5)
    
5.  通过得分对流派进行排序，当第一流派分数 > 第二流派分数 \* 2.0时，第一流派胜出，作为track\_genre\_idx。否则track\_genre\_idx=fallback\_idx
    
6.  track\_genre\_idx的设计思路是，当声学相近的歌曲中大量出现某个流派，且该流派排名靠前时，这个流派就是track\_genre\_idx，而不是简单使用artist\_genre\_idx
    
7.  track\_genre\_idx会作为筛选条件，筛掉candidates中流派与track\_genre\_idx不同的结果。这样当用户输入齐豫的佛教音乐，经过get\_track\_genre\_idx函数能得到佛教音乐的genre\_idx，通过这个genre\_idx过滤掉非佛教音乐的candidates结果，得到的结果纯度会高很多
    

**效果展示**

*   种子曲目：《周杰伦 - 晴天》
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/4271d528-d1a8-4529-9f72-882883ce7f99.png)

*   种子曲目：《梶浦由記 - Decretum》
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/1b49dde6-555c-49b2-957c-0b65fac69823.png)

*   种子曲目：《Oskar Schuster - Gizeh》
    

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/b1c7a3ec-6675-446e-821c-d9d21279da48.png)

**分析**

*   v0.4推荐结果比v0.3好非常多，看到周杰伦能推荐G.E.M，看到梶浦由记能推荐泽野弘之，看到Oskar Schuster能推荐Ludovico Einaudi，而且返回的前几首歌曲风格与种子曲目非常接近，调式调性、节奏韵律都很像，结合了声学相似+风格相似的优点
    

## Embeat v0.5：多路召回

**目标**

*   通过阅读Spotify与网易云的技术报告可知，目前主流的音乐厂商在做歌曲推荐系统时都会用到多路召回。它指的是推荐的歌曲不应该从单一路径获取，而是多角度的，通过多种途径获取，汇总后通过一定规则过滤、重排、输出
    
*   目前的Embeat只有“流派声学相似”这一路输出，因此需要为其设计更多输出路径
    

**第二路输出：同流派高热度**

*   思路：由于声学特征的过滤，大量同流派但能量、配器不同的歌曲可能被过滤，这就导致用户只会听到一种情绪的音乐，同质化比较严重。引入这一路，将同流派中热度高的歌曲推荐给听众，由于热度高，听众很可能曾经听过这部分音乐，能给予其更多亲切感
    
*   方法：通过artist\_genres (str) 转 artist\_genre\_idxs (List\[int\])，从多个流派中筛选歌曲，最后按热度从高到低排序输出
    
*   可能的问题：假如种子曲目是《齐豫 - 橄榄树》，但假如这一路推荐了齐豫唱的佛经音乐，会带来割裂感。好在佛经音乐的热度普遍不高，通过热度排序，排名靠前的全是华语经典、华语流行
    
*   优质例子：种子曲目《Gareth Coker - Light of Nibel》，这是一首高能量的带人声咏唱的管弦乐作品。经过这一路推荐后，获得了《Berlinist - Gris, Pt. 2》这首低能量，但同为咏唱风格的游戏音乐。我在体验Spotify过程中，也得到了相同的返回结果
    

**第三路输出：同歌手高热度**

*   思路：假如歌手实在太小众，第一、二路输出都可能获取不到同歌手的音乐作品，而同歌手的音乐无论风格与种子曲目相差有多大，听到同歌手总能带给用户熟悉感，所以需要引入这一路输出
    
*   方法：根据artist\_idx (int)搜索同歌手作品，不加其它任何筛选条件，最后按流行度从高到低排序输出
    
*   重排：如果在输出的candidates中发现同专辑作品，插入到队头，拥有更高的推荐权重
    
*   控制数量：最终返回的同歌手曲目数量不能太多也不能太少，这里我引入same\_artist\_ratio\_range这个参数，我将在后续的重排部分展开介绍
    
*   优质例子：种子曲目《潘安邦 - 爸爸的草鞋》（低能量小调音乐），推荐歌曲《潘安邦 - 外婆的澎湖湾》（低能量大调音乐），同歌手但风格可能迥然不同，带来熟悉感与破圈感
    

**第四路输出：相似歌手（当前版本新增）**

*   思路：歌手级的协同过滤。假设歌手A可能与歌手B风格迥然不同，但他们在文化圈中有交集，他们的音乐也因此有了共性。完成这一路输出，整套推荐系统会有质的飞跃，才能做到真正懂用户
    
*   准备工作：将45M数据集中的artist\_id与artist\_idx批量导出，通过某API多线程爬取Spotify上的related\_artist接口，得到artist\_id (str) -> artist\_related\_ids (List\[str\])这样的映射关系。最后重新映射回related\_artist\_idxs (List\[int\])，作为一个新字段保存进Qdrant数据库
    
*   进度：共324万艺人，为105万艺人找到相似歌手，构建起庞大的歌手关系网络
    
*   方法：将artist\_related\_ids与query\_vector传进Qdrant，找到满足artist\_related\_ids的艺人作品，最后按与种子曲目声学向量距离排序输出
    
*   优质例子：种子曲目《Lia - 鳥の詩》（来自Key社动漫《AIR》），推荐曲目《riya - 小さなてのひら》（来自Key社动漫《CLANNAD》）。这种圈内人才懂的推荐，如今Embeat也能实现
    

~~**第五路输出：歌单协同过滤**~~**（v0.6新增）**

*   \[ 参考本文章节v0.6 \]
    

**重排输出**

*   对于top\_k=20的结果，会从四路输出中得到80个candidates结果
    
*   首先四路输出在进入重排阶段前，之前会经过筛选处理，比如筛除掉相同track\_name、带remix的曲目、低于最低热度的曲目（目前值为0.01）等操作，确保每一路的candidates都尽可能接近20，不浪费槽位
    
*   进入重排阶段后，会对这四路结果做ISRC过滤，排除掉那些相同音乐但因发行地区不同的原因出现的重复曲目，这些曲目无法通过track\_name简单过滤，比如《周杰伦 - 青花瓷》《Jay Chou - Qing Hua Ci》
    
*   接下来每个candidate进行打分，具体运作流程是：
    

1.  来自第一路声学相似输出，给1.7~1.0分
    
2.  来自第二路同流派高热度输出，给1.0~0.8分
    
3.  来自第三路同歌手高热度输出，给1.9~1.0分
    
4.  来自第四路相似歌手输出，给1.8~1.0分
    
5.  ~~来自第五路歌单协同过滤输出，给2.0~1.0分~~
    
6.  浮动分是根据candidate所在那路输出的index给的分数，因为四路输出都做过排名处理，排名靠前的价值更高
    
7.  一个candidate可能命中多路输出，分数可累加，这种candidate很容易在最终阶段胜出
    

*   由于第二路输出给的分数偏低，很可能在最终20个输出中拿不到席位。所以加了个保底机制，10%的歌曲必须来自第二路输出，插到队尾中，给推荐列表一定的破圈感
    

**效果展示**

*   种子曲目：《周杰伦 - 晴天》
    

```python
Query track_id: 5pIcwtJYNJx93l420oR2Vm
Query track info: 晴天 - Jay Chou
======= Top 20 items =======
index   track_id                track_name      artist_name     album_name      sources
1       6USP6g71C4b6wkRyR5sabC  奇蹟 Miracle    G.E.M.  The Best of G.E.M. 2008 - 2012  ['similar', 'related_artist']
2       2tqF9MPNdYdJU70U0ULO23  告白氣球        Jay Chou        周杰倫的床邊故事        ['popular', 'same_artist']
3       5INxVTneIiVxUprpJFyEBl  在你耳邊說      Eric Chou       戀愛筆記        ['similar', 'related_artist']
4       4BlnR7y7sDouqBggjzuZxR  說了再見        Jay Chou        跨時代  ['same_artist']
5       1SMC4asXLbJMhckDg1PKAq  Six Degrees     Jay Chou        Six Degrees     ['same_artist']
6       0L0YcC8trjXcGi7gUUTTJY  年少有为        Ronghao Li      2019中国好声音巅峰之夜  ['related_artist']
7       3p4UTiSIIpP4LFn0KEyEOj  十面埋伏        Eason Chan      Live For Today  ['related_artist']
8       21aqdGfLlYC3nRI86JQ8oz  阿怪    Eason Chan      黑·白·灰        ['related_artist']
9       4xTN75476Zux2ZkQc6Vyyp  談戀愛  Della   談戀愛  ['similar']
10      6CBQGet200AWXYTKlt7LzJ  突然好想你      Mayday  五月天 | 步步 | 自選作品輯 the Best of 1999-2013        ['related_artist']
11      7oKRQKokAtk3MKB78K6LBD  Thrilled        Wanting         Everything In The World (Celebration Version)   ['similar']
12      6MeN0fn6XodP7N3VZx59EU  粉筆和塗鴉      Pets Tseng      粉筆和塗鴉      ['similar']
13      6grLvolCX0Y6JbkhLEDBdB  夢的可能        Eason Chan      準備中  ['related_artist']
14      0AuGbsggLNbfx0pfYLW8Ve  奇蹟    G.E.M.  The Best of G.E.M. 2008 - 2012 (Deluxe Version)         ['similar']
15      1CFob3XXGftpT5hZGusvWV  我曾用心爱着你 - Live   潘美辰  潘美辰演唱会现场录音特辑 (Live)         ['similar']
16      6Aq2oV748LSfZdp5FieYZD  極光    Bii     電視劇《解憂公主》      ['similar']
17      3pyfEMTvunanVkcTFME2IK  小時候的我們    Eric Chou       小時候的我們    ['related_artist']
18      28WXeygFMCoYWq6QpZ882f  沙林    ATK     沙林    ['similar']
19      7sNdTYUKm8Lom5hjrn1765  瞬间    成方圆  九州方圆——电视歌会      ['similar']
20      2gug6MRv4xQFYi9LA3PJCS  你，好不好？ - TVBS連續劇【遺憾拼圖】片尾曲     Eric Chou       愛，教會我們的事        ['popular']
```

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/4bd4f86e-f7b5-4332-8291-0f47383a043f.png)

*   种子曲目：《梶浦由記 - Sis puella magica!》
    

```python
Query track_id: 0HDh5QaLfCwi2xyvkei917
Query track info: Sis puella magica! - 梶浦 由記
======= Top 20 items =======
index   track_id                track_name      artist_name     album_name      sources
1       15qBxGjAD4kWONlicpYpuR  Puella in somnio        梶浦 由記       「魔法少女まどか☆マギカ」 Music Collection      ['same_artist']
2       6AICX6AYZYfM4lU24Fs08m  Decretum        梶浦 由記       「魔法少女まどか☆マギカ」 Music Collection      ['same_artist']
3       72SGuXbyrObLqljoNEscPq  Dragon  KOKIA   KOKIA 25th Anniversary Best -The Lighthouse- vol.1      ['related_artist']
4       4SBbneN8BP416V3gZgrxPy  vanity ((LIVE))         FictionJunction         FictionJunction 2008-2010 The BEST of Yuki Kajiura LIVE         ['related_artist']
5       6RidxKPzPY91OK4CX9mpfG  gunland         梶浦 由記       ソードアート・オンライン ミュージックコレクション       ['same_artist']
6       4dEtOeOPiMLS1JFmXNNfU7  ドラゴン        KOKIA   The Animal Kingdom vol.4        ['related_artist']
7       0gJLT6VVo8sfGT2VfP4zLP  Conturbatio     梶浦 由記       「魔法少女まどか☆マギカ」 Music Collection      ['same_artist']
8       4nqshrmkaHP6gxg5fuHKRQ  Forbidden Love  Cécile Corbel   Kari-gurashi    ['similar']
9       0QKqLi8BiJb1LWjiwCWWWt  なつのおもいで  Hideyuki Fukasawa       月姫 -A piece of blue glass moon- Original Soundtrack   ['related_artist']
10      401z2mPimsxygC60yte7mO  Bran (2016 Remastered)  Cécile Corbel   Harpe celtique et chants du monde (2016 Remastered)     ['similar']
11      69YY8LxMWOqcFPoqxVZBRz  Distance        FictionJunction         Distance ／ eternal blue        ['related_artist']
12      4VSzSJJBJdpFW7Pqpdp3LT  Flag    Wakana  そのさきへ      ['related_artist']
13      480g4Wdq5XeLqURUaQIBzI  Angel of Doom   Shiro SAGISU    Evangelion: 1.0 You Are (Not) Alone (Original Soundtrack Album)         ['similar']
14      1GahVE98RPgqBymlM1RDV6  serenato        Kalafina        Seventh Heaven  ['related_artist']
15      0QBH1oiMzl9lvQl5EKP2og  銀鷗的迴旋      HOYO-MiX        原神-幽暮襯映之月 (遊戲《原神》原聲音樂)        ['similar']
16      4JrASFUGwmeneZe47HpIR3  九月    Kalafina        After Eden      ['related_artist']
17      3GFvv1FS9oPx7yC9nsO5QV  Shir al etz (2016 Remastered)   Cécile Corbel   Harpe celtique et chants du monde (2016 Remastered)     ['similar']
18      2b9xJJDHQN1NTk1XkVT0vo  メインテーマ／眠り      Hideyuki Fukasawa       魔法使いの夜 オリジナルサウンドトラック         ['related_artist']
19      2ND8SkQ3Rav1x4zpmTwA3E  going home      Shiro SAGISU    TV Animation BLEACH Original Soundtrack 1       ['popular']
20      4dHOnPucB5VBYq3gjRtYy9  You See Big Girl / T:T  Sawano Hiroyuki         TV Anime "Attack on Titan Season 2" (Original Soundtrack)       ['popular']
```

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/edb8da76-a707-4ea6-92f6-a4f0806ac542.png)

*   种子曲目：《Oskar Schuster - Gizeh》
    

```python
Query track info: Gizeh - Oskar Schuster
======= Top 20 items =======
index   track_id                track_name      artist_name     album_name      sources
1       7EYle14QET9bWMMxfI2yeS  Für Ofelia      Oskar Schuster  Für Ofelia      ['same_artist']
2       74x7IsqYo9IwXF5K1Yu180  Vervlogen       Oskar Schuster  Singur  ['same_artist']
3       0d7dpG6tUWelr6FiWwF9uy  Maribel         Oskar Schuster  Maribel         ['same_artist']
4       66Q6qHI1mPeGiUj85F9ZGu  Historiette No. 5       Fabrizio Paterlini      Historiette No. 5       ['related_artist']
5       7bDSSxeXWsoBAea0cBGRHe  Keys for Kids   Gian Marco La Serra     Piano Whispers 01       ['related_artist']
6       4YYBYxpyLzniQw3ftqUf7W  Fantaisie       Oskar Schuster  Elixía  ['same_artist']
7       5qVwIAzhqECrb40fuRY3g0  Riddle  Matt Stewart-Evans      Solo II         ['related_artist']
8       5Ey5IykoI059V2ibfrVThZ  24 Preludes and Fugues, Op. 87: Fugue No. 2 in A Minor "Allegretto"     Dmitri Shostakovich     Shostakovich: 24 Preludes and Fugues    ['similar']
9       4QSqk7blTTNO4DuctGu8N9  Matilda         Oskar Schuster  Xorkia (Collected Singles)      ['same_artist']
10      7bWHTgGjDqGN0eoKMh0Fs1  Week #10 - Live         Fabrizio Paterlini      Live in Bratislava      ['related_artist']
11      3ml88Fc0tgun50FyxKQVBi  Adams: John's Book Of Alleged Dances: Stubble Crotchet  Kronos Quartet  25 Years        ['similar']
12      01HoInikQgofDHG7eYfa4o  Leicester       Lambert         Alone II        ['similar']
13      7EWYdhDukbN6clqu9yfL05  Week No. 9      Fabrizio Paterlini      Autumn Stories  ['related_artist']
14      0kSUqCeU3D7eeq9Z65fYpu  Marlen In Love  Lambert         Alles in bester Ordnung (Original Motion Picture Soundtrack)    ['similar']
15      4yyqznbhQpzvuAY14rpA9X  24 Preludes & Fugues, Op. 87: No. 2b, Fugue in A Minor  Dmitri Shostakovich     "Pianissimo - Classical Masterpieces"   ['similar']
16      56Z1kxbqvraUNNZjk2A5oI  24 Preludes and Fugues, Op. 87: Fugue No. 2 in A Minor: Allegretto      Dmitri Shostakovich     Shostakovich: 24 Preludes and Fugues, Op. 87    ['similar']
17      0syXiT3P1NI6dKdxDVCtPK  Fractions       Gabriel Parker  KEEN: Complete Concentration Vol. 1     ['related_artist']
18      0XaUDFrmop9zsrfKqXeHBp  Song for the Village    Federico Albanese       Blackbirds and the Sun of October       ['related_artist']
19      0VLascXUZ2Ze2u5kUD5J1f  I Giorni        Ludovico Einaudi        Islands - Essential Einaudi     ['popular']
20      1ZC21hJb2GASRkaoZvUcCS  Beving: Ala     Joep Beving     Trilogy         ['popular']
```

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/3e82472c-c1da-44fd-bb24-b49c6671c70e.png)

**分析**

*   v0.5推荐结果相当惊艳，简直是断代升级！第一路声学相似similar确保了速度、调式、调性、情感的相似，第二路同流派高热度popular确保了10%的保底探索，第三路同歌手高热度same\_artist给用户带来归家感、舒适圈，第四路相似歌手related\_artist带来了文化圈的交集
    
*   但v0.5仍存在一些小问题，比如《周杰伦 - 晴天》推荐了两首G.E.M.的《奇迹》，后来经检查发现ISRC的确不同，因此我在v1.0用了其它规则手段解决这个问题；以及相同艺人扎堆出现在头部的问题（同艺人+同风格+高热度，导致多源命中而排名靠前），在v1.0也通过强制重排序的手段得以修复
    
*   而后v0.6引入的歌单级协同过滤本质是第四路related\_artist的增强版，带来更加深入的文化圈交集，更懂听众想听什么（见下文）
    

## Embeat v0.6：引入歌单级协同过滤

**目标**

*   爬虫并清洗Spotify Playlist数据。目前已采集到6000万份Spotify Playlist数据等待清洗
    
*   根据之前采集到的related\_artist网络，建立playlist-artist-track之间的联系，抽出在playlist中共现度最高的track，创建related\_track网络，新增第五路输出结果
    
*   同时要避免出现网易云高度依赖“UGC用户自建歌单”的协同过滤，导致标签被大众用户的随意组合所污染这种情况
    

**清洗数据**

*   目标是将这些歌单数据清洗成txt数据，一行代表一个歌单，用空格分隔歌曲ID
    
*   符合条件的歌单：5 <= 歌曲总数 <= 100；收藏数 > 0；歌单名非空
    
*   符合条件的歌曲：非本地文件；非播客数据；track\_id需要在上述45M Qdrant数据库中有值
    
*   丢弃筛选后歌曲总数<5的歌单
    
*   丢弃来自同一张专辑的歌单（重要，有些用户会直接将专辑转成歌单保存）
    
*   最后获得188万份歌单数据（txt），以下是txt数据的分析结果：
    

```python
Playlist count: 1885651
Playlist min track count: 5
Playlist max track count: 100
Playlist median track count: 48

All track count: 9360252
Track appears in >= 5 playlists count: 2034982
Track appears in >= 10 playlists count: 1093603
Track appears in >= 20 playlists count: 578127
Track appears in >= 50 playlists count: 231827
```

**方案A：加权共现统计**

*   计算每一首歌曲与其它歌曲在188万份歌单中的共现次数，然后排序，保存Top10的结果
    
*   当然要设置一个共现次数阈值，比如歌曲A与歌曲B出现在>=10份歌单中，才记录这对关系，否则抛弃
    
*   共现统计可能导致热门歌曲出现概率偏高，比如歌曲A与歌曲B同为热门歌曲，那么它们会在推荐中大量出现，而冷门歌曲C与热门歌曲A虽然相似，但共现次数少，可能不会被推荐。因此可以使用Jaccard相似度或PMI点互信息的方式，压制热门歌曲假相似，放大小众圈子真相似
    
*   最终产物是一份JSON数据（假设共现阈值设为10），key为109万条歌曲ID（str），value为它们的Top10共现歌曲ID（list）
    

**方案B：Track2Vec（最终使用）**

*   Track2Vec其实就是用Word2Vec的思路，只是单词变成了track\_id，句子变成playlist。因为歌单里的单曲是有上下文含义的，表示它们的关联性，所以可以用这种方式映射track与track的向量空间关系
    
*   Track2Vec相比加权共现统计来说，它能更好地捕捉歌曲与歌曲之间的更广泛的关系。比如歌曲A与歌曲B在歌单A中，歌曲A与歌曲C在歌单B中，Track2Vec能在向量空间找到歌曲B与歌曲C的关系，而加权共现统计并不能。或者说加权共现统计是Track2Vec的一个零阶近似
    
*   训练使用Gensim包的Word2Vec实现，调参参考论文《Word2vec applied to Recommendation: Hyperparameters Matter》（[https://arxiv.org/abs/1804.04212](https://arxiv.org/abs/1804.04212)），具体参数如下：
    

```python
LineSentence = LineSentence(training_data)   # 使用Gensim自带的LineSentence加载txt训练数据
vector_size = 64   # 参考EmbeatMLP的向量维度为64，因为需要在CPU机器上推理，维度不宜过大。实测过64与128推理差别不大，为保障推理速度选64
min_count = 10   # 核心参数，影响vocab长度。参考上述txt训练数据的分析结果，109万的vocab属于非常合理的区间
sg = 1   # 使用Skip-Gram，用中心词预测上下文，适合稀疏数据分布，且符合推理场景（由seed_track推related_tracks）
window = 100   # 训练时每个中心歌曲能看到前后各n首歌曲作为上下文，100代表将歌单视为无序bag，歌单内所有歌曲均参与训练
negative = 20   # 负采样数量，每个正样本对应20个随机负样本，数值越大训练速度越慢。数值参考Word2Vec论文推荐取值范围
sample = 1e-3   # 高频词降采样阈值，数值越小对热门歌曲的抑制越明显。因为我有其它路召回，因此反而不应该加热门抑制
epochs = 60   # 训练轮数
ns_exponent = -0.5   # 论文重要发现，负样本采样规则。0=均匀采样，>0采样高频样本，<0采样长尾样本
```

*   训练日志：（gensim\_loss在多线程下有bug，建议参考mean\_delta值，两个epoch的参数相减取平均）
    
    ```python
    Start training...
    2026-05-28 23:41:34,407 INFO Epoch 0/60 | gensim_loss=10697942.0000 | mean_delta=4.1419
    2026-05-28 23:44:51,709 INFO Epoch 1/60 | gensim_loss=5617480.0000 | mean_delta=1.0037
    2026-05-28 23:48:09,558 INFO Epoch 2/60 | gensim_loss=4657060.0000 | mean_delta=0.7313
    2026-05-28 23:51:23,022 INFO Epoch 3/60 | gensim_loss=5056134.0000 | mean_delta=0.6386
    2026-05-28 23:54:40,028 INFO Epoch 4/60 | gensim_loss=4859238.0000 | mean_delta=0.5931
    2026-05-28 23:57:57,542 INFO Epoch 5/60 | gensim_loss=4265310.0000 | mean_delta=0.5658
    2026-05-29 00:01:15,150 INFO Epoch 6/60 | gensim_loss=3753824.0000 | mean_delta=0.5469
    2026-05-29 00:04:33,429 INFO Epoch 7/60 | gensim_loss=3436768.0000 | mean_delta=0.5320
    2026-05-29 00:07:50,205 INFO Epoch 8/60 | gensim_loss=3683332.0000 | mean_delta=0.5196
    2026-05-29 00:11:04,196 INFO Epoch 9/60 | gensim_loss=3505080.0000 | mean_delta=0.5084
    2026-05-29 00:14:22,693 INFO Epoch 10/60 | gensim_loss=3895364.0000 | mean_delta=0.4984
    2026-05-29 00:17:38,231 INFO Epoch 11/60 | gensim_loss=3717476.0000 | mean_delta=0.4887
    2026-05-29 00:20:51,954 INFO Epoch 12/60 | gensim_loss=3599576.0000 | mean_delta=0.4796
    2026-05-29 00:24:08,539 INFO Epoch 13/60 | gensim_loss=3526380.0000 | mean_delta=0.4705
    2026-05-29 00:27:24,021 INFO Epoch 14/60 | gensim_loss=3292308.0000 | mean_delta=0.4614
    2026-05-29 00:30:37,936 INFO Epoch 15/60 | gensim_loss=1675064.0000 | mean_delta=0.4525
    2026-05-29 00:33:52,654 INFO Epoch 16/60 | gensim_loss=1668216.0000 | mean_delta=0.4438
    2026-05-29 00:37:07,566 INFO Epoch 17/60 | gensim_loss=1726856.0000 | mean_delta=0.4349
    2026-05-29 00:40:22,895 INFO Epoch 18/60 | gensim_loss=1666640.0000 | mean_delta=0.4261
    2026-05-29 00:43:43,298 INFO Epoch 19/60 | gensim_loss=1691360.0000 | mean_delta=0.4176
    2026-05-29 00:47:00,953 INFO Epoch 20/60 | gensim_loss=1714128.0000 | mean_delta=0.4087
    2026-05-29 00:50:15,335 INFO Epoch 21/60 | gensim_loss=1690992.0000 | mean_delta=0.3997
    2026-05-29 00:53:32,367 INFO Epoch 22/60 | gensim_loss=1686600.0000 | mean_delta=0.3909
    2026-05-29 00:56:46,125 INFO Epoch 23/60 | gensim_loss=1694440.0000 | mean_delta=0.3820
    2026-05-29 01:00:00,575 INFO Epoch 24/60 | gensim_loss=1683208.0000 | mean_delta=0.3731
    2026-05-29 01:03:12,995 INFO Epoch 25/60 | gensim_loss=1588208.0000 | mean_delta=0.3641
    2026-05-29 01:06:27,510 INFO Epoch 26/60 | gensim_loss=1766392.0000 | mean_delta=0.3552
    2026-05-29 01:09:40,711 INFO Epoch 27/60 | gensim_loss=1607168.0000 | mean_delta=0.3461
    2026-05-29 01:12:55,208 INFO Epoch 28/60 | gensim_loss=1757848.0000 | mean_delta=0.3372
    2026-05-29 01:16:12,403 INFO Epoch 29/60 | gensim_loss=1759744.0000 | mean_delta=0.3280
    2026-05-29 01:19:27,043 INFO Epoch 30/60 | gensim_loss=1715968.0000 | mean_delta=0.3190
    2026-05-29 01:22:42,381 INFO Epoch 31/60 | gensim_loss=1754432.0000 | mean_delta=0.3098
    2026-05-29 01:25:58,849 INFO Epoch 32/60 | gensim_loss=1605992.0000 | mean_delta=0.3006
    2026-05-29 01:29:14,724 INFO Epoch 33/60 | gensim_loss=1680360.0000 | mean_delta=0.2915
    2026-05-29 01:32:28,661 INFO Epoch 34/60 | gensim_loss=1715792.0000 | mean_delta=0.2820
    2026-05-29 01:35:43,445 INFO Epoch 35/60 | gensim_loss=1824312.0000 | mean_delta=0.2726
    2026-05-29 01:38:57,053 INFO Epoch 36/60 | gensim_loss=1802056.0000 | mean_delta=0.2632
    2026-05-29 01:42:15,332 INFO Epoch 37/60 | gensim_loss=1828976.0000 | mean_delta=0.2539
    2026-05-29 01:45:30,897 INFO Epoch 38/60 | gensim_loss=1768280.0000 | mean_delta=0.2444
    2026-05-29 01:48:48,761 INFO Epoch 39/60 | gensim_loss=1706584.0000 | mean_delta=0.2348
    2026-05-29 01:52:08,388 INFO Epoch 40/60 | gensim_loss=1848656.0000 | mean_delta=0.2252
    2026-05-29 01:55:23,611 INFO Epoch 41/60 | gensim_loss=1694264.0000 | mean_delta=0.2156
    2026-05-29 01:58:41,017 INFO Epoch 42/60 | gensim_loss=1755352.0000 | mean_delta=0.2058
    2026-05-29 02:02:00,063 INFO Epoch 43/60 | gensim_loss=1725520.0000 | mean_delta=0.1959
    2026-05-29 02:05:17,193 INFO Epoch 44/60 | gensim_loss=1824152.0000 | mean_delta=0.1860
    2026-05-29 02:08:35,027 INFO Epoch 45/60 | gensim_loss=1801016.0000 | mean_delta=0.1760
    2026-05-29 02:11:54,045 INFO Epoch 46/60 | gensim_loss=1757320.0000 | mean_delta=0.1659
    2026-05-29 02:15:12,126 INFO Epoch 47/60 | gensim_loss=1777720.0000 | mean_delta=0.1557
    2026-05-29 02:18:28,780 INFO Epoch 48/60 | gensim_loss=1861072.0000 | mean_delta=0.1454
    2026-05-29 02:21:47,227 INFO Epoch 49/60 | gensim_loss=1845992.0000 | mean_delta=0.1349
    2026-05-29 02:25:09,127 INFO Epoch 50/60 | gensim_loss=1883192.0000 | mean_delta=0.1243
    2026-05-29 02:28:29,265 INFO Epoch 51/60 | gensim_loss=1769672.0000 | mean_delta=0.1136
    2026-05-29 02:31:53,848 INFO Epoch 52/60 | gensim_loss=1845152.0000 | mean_delta=0.1026
    2026-05-29 02:35:12,050 INFO Epoch 53/60 | gensim_loss=485760.0000 | mean_delta=0.0915
    2026-05-29 02:38:34,214 INFO Epoch 54/60 | gensim_loss=0.0000 | mean_delta=0.0801
    2026-05-29 02:41:57,032 INFO Epoch 55/60 | gensim_loss=0.0000 | mean_delta=0.0684
    2026-05-29 02:45:17,398 INFO Epoch 56/60 | gensim_loss=0.0000 | mean_delta=0.0564
    2026-05-29 02:48:36,465 INFO Epoch 57/60 | gensim_loss=0.0000 | mean_delta=0.0442
    2026-05-29 02:51:56,200 INFO Epoch 58/60 | gensim_loss=0.0000 | mean_delta=0.0317
    2026-05-29 02:55:16,690 INFO Epoch 59/60 | gensim_loss=0.0000 | mean_delta=0.0199
    DONE! Vocab length: 1093603
    ```
    
*   最终产物是track2vec.wv（词表）与track2vec.wv.vectors.npy（向量），109万的词表，两个文件加起来不到300MB，推理时完全可以全量加载进内存
    

**Track2Vec推理例子**

```python
Sis puella magica! - 梶浦 由記 related tracks:
  4S40MDl8IuzSHsmsctWaKI  score=0.9334
  0BEnKTrkKp5saTb2iFxnT1  score=0.9325
  0gJLT6VVo8sfGT2VfP4zLP  score=0.9310
  7pzGD0fepRx0gwZKUrHdBH  score=0.9285
  1maYnq1a9WoiNUPaTcs9uR  score=0.9277
  69dDnfotBtbj2hVHWpeKhO  score=0.9269
  6AICX6AYZYfM4lU24Fs08m  score=0.9258
  4sPKrqfM6lBaCX0COmOgzQ  score=0.9247
  3QnYPtjMm8YxCxgEI6pz0R  score=0.9230
  42fHftcN2NYb0g8x4nRzhL  score=0.9210
Query used time: 0.127ms

Tori no Uta - VISUAL ARTS / Key related tracks:
  3skl1XsMWVYXPOwq3BHRDG  score=0.9642
  5WuuZ9w47sBHBEU14KdLv0  score=0.9530
  4VTxO7SMtu0AcGqazV15AL  score=0.9519
  0vnQh69kXw7PqpyWsOgFMz  score=0.9511
  0CDma8JyHAPKM4qpJlonfA  score=0.9510
  3tXmPXQBCkAlo0FICWetwz  score=0.9505
  5wUWbDQRB3KR1JKT9oEMbX  score=0.9504
  2yyeFsdpzTIH8E9PBtNxHw  score=0.9477
  1jd2jYSUu3reC3rR6EAwYB  score=0.9452
  5SXk7a9m3bvvDOQl75K4cF  score=0.9441
Query used time: 0.018ms

Uptown Funk (feat. Bruno Mars) - Bruno Mars related tracks:
  6b8Be6ljOzmkOmFslEb23P  score=0.9380
  6JV2JOEocMgcZxYSZelKcc  score=0.9290
  57n3qOwXcoRMyGFjeqC1Rh  score=0.9155
  60nZcImufyMA1MKQY3dcCH  score=0.9035
  55h7vJchibLdUkxdlX3fK7  score=0.9001
  2iuZJX9X9P0GKaE93xcPjk  score=0.8931
  4kbj5MwxO1bq9wjT5g9HaA  score=0.8849
  2Foc5Q5nqNiosCNqttzHof  score=0.8824
  4rmFRTmHa2bWUmMLIRVEXQ  score=0.8808
  7pYfyrMNPn3wtoCyqcTVoI  score=0.8785
Query used time: 0.016ms
```

**效果展示（related\_track加入多路召回）**

*   种子曲目：《梶浦由記 - Sis puella magica!》
    

```python
Query track_id: 0HDh5QaLfCwi2xyvkei917
Query track info: Sis puella magica! - 梶浦 由記
Query artist genres: ['anime score', 'japanese vgm']
-> Find query record used time: 2ms
-> Similar recall used time: 31ms
-> Popular recall used time: 13ms
-> Same artist recall used time: 3ms
-> Related artist recall used time: 3ms
-> Related track recall used time: 180ms
-> Re-ranking used time: 4ms
Result artist genres: ['anime score', 'mecha', 'japanese vgm', 'anime']
======= Top 20 items =======
index   track_id                track_name      artist_name     album_name      sources         score
1       0gJLT6VVo8sfGT2VfP4zLP  Conturbatio     梶浦 由記       「魔法少女まどか☆マギカ」 Music Collection      ['same_artist', 'related_track']        1.0
2       2nOcNupR8YMJFAB1ew1a7L  Desiderium      梶浦 由記       「魔法少女まどか☆マギカ」 Music Collection      ['same_artist', 'related_track']        1.0
3       75dXCLRNjxt97ZxytZwEZn  Noi!    梶浦 由記       「魔法少女まどか☆マギカ」 Ultimate Best         ['same_artist', 'related_track']        1.0
4       3n4MLZyy29Cqpa2SNxGSWv  輝く空の静寂には        Kalafina        After Eden      ['related_artist']      0.9
5       6I1hV9NMDrwhbymhdA2P8X  ARIA    Kalafina        Seventh Heaven  ['related_artist']      0.88
6       2CbcuWo8KUs3psdpV5OJ0i  優しい夜明け    See-Saw         Dream Field     ['related_artist']      0.86
7       4nqshrmkaHP6gxg5fuHKRQ  Forbidden Love  Cécile Corbel   Kari-gurashi    ['similar']     0.85
8       0Zya2iWNaZk3SngvH8sQFB  花守の丘        FictionJunction         Everlasting Songs       ['related_artist']      0.84
9       2Yj0xm6WNvEx4X1oGPdlGl  Brilliant       Yuki Hayashi    TVアニメ『ボールルームへようこそ』オリジナルサウンドトラック    ['similar']     0.83
10      3TZfOYS67mtB7scdml7CBj  Gloria  Kalafina        Lacrimosa       ['related_artist']      0.82
11      0cvVx7TnD8wSpcM49TyjsB  Arrietty's Song(Arrietty's Song)        Cécile Corbel   借りぐらしのアリエッティ サウンドトラック       ['similar']     0.81
12      16BZEyW9bSbvNIX7xfPshD  Forrest Song    Kevin Penkin    ”Spice and Wolf:MERCHANT MEETS THE WISE WOLF”Original Soundtrack Vol.1  ['similar']     0.79
13      4GwRl3fIx1UsCr3x1CYRxl  EM21 no choir   Shiro SAGISU    Shiro SAGISU outtakes from Evangelion   ['similar']     0.77
14      2UekJVxWxYfEyUbgCSCwsi  Les Passagers Du Vent   Cécile Corbel   La Fiancée      ['similar']     0.75
15      3JTJGb9OwunRDXSQlWYJ0p  新日本紀行      Taro Hakase     Dal Segno Story of My Life      ['similar']     0.73
16      480g4Wdq5XeLqURUaQIBzI  Angel of Doom   Shiro SAGISU    Evangelion: 1.0 You Are (Not) Alone (Original Soundtrack Album)         ['similar']     0.71
17      3QnYPtjMm8YxCxgEI6pz0R  黒点    藤澤慶昌        TVアニメ『宝石の国』オリジナルサウンドトラック  ['related_track']       0.67
18      60Tgoqdfj5pyYUCSaNChC0  Marginalia #143         Masakatsu Takagi        Marginalia VII  ['similar']     0.65
19      6AFkv6rIVRusZNifR74Q7t  Shingeki Gt 20130218 Kyojin     Sawano Hiroyuki         TV Anime "Attack on Titan Season 2" (Original Soundtrack)       ['popular']     0.5
20      6TT1rkkXEDaITj1xSOovbY  Zoltraak        Evan Call       Frieren: Beyond Journey's End (Original Soundtrack)     ['popular']     0.49

Query used time: 0.239s
```

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/ec268dd3-a087-47a5-b22b-71d13dec5d36.png)

*   种子曲目：《Bruno Mars - Uptown Funk》
    

```python
Query track_id: 32OlwWuMpZ6b0aN2RZOeMS
Query track info: Uptown Funk (feat. Bruno Mars) - Bruno Mars
Query artist genres: ['dance pop', 'pop']
-> Find query record used time: 2ms
-> Similar recall used time: 28ms
-> Popular recall used time: 10ms
-> Same artist recall used time: 2ms
-> Related artist recall used time: 3ms
-> Related track recall used time: 176ms
-> Re-ranking used time: 1ms
Result artist genres: ['pop', 'dance pop', 'modern rock', 'australian pop', 'filter house']
======= Top 20 items =======
index   track_id                track_name      artist_name     album_name      sources         score
1       0KKkJNfGyhkQ5aFogxQAPU  That's What I Like      Bruno Mars      24K Magic       ['popular', 'same_artist', 'related_track']     1.0
2       3w3y8KPTfNeOKPiqUTakBh  Locked out of Heaven    Bruno Mars      Unorthodox Jukebox      ['popular', 'same_artist', 'related_track']     1.0
3       55h7vJchibLdUkxdlX3fK7  Treasure        Bruno Mars      Unorthodox Jukebox      ['same_artist', 'related_track']        1.0
4       3cHyrEgdyYRjgJKSOiOtcS  Timber  Pitbull         Global Warming: Meltdown (Deluxe Version)       ['popular', 'related_track']    1.0
5       7uT1gmdO1Zm4wvSJxrp8kv  Give A Little More      Maroon 5        Hands All Over (Revised International Standard version)         ['related_artist']      0.9
6       6JV2JOEocMgcZxYSZelKcc  CAN'T STOP THE FEELING! (from DreamWorks Animation's "TROLLS")  Justin Timberlake       CAN'T STOP THE FEELING! (from DreamWorks Animation's "TROLLS")  ['related_track']       0.87
7       4Xf7EL8G6FRYwvJADl0nf4  Bass Down Low   DEV     Bass Down Low   ['similar']     0.85
8       60nZcImufyMA1MKQY3dcCH  Happy - From "Despicable Me 2"  Pharrell Williams       G I R L         ['related_track']       0.85
9       0yg6n1GI3n6oiC1D1vdncA  Ladykiller      Maroon 5        Overexposed (Deluxe)    ['related_artist']      0.83
10      2iuZJX9X9P0GKaE93xcPjk  Sugar   Maroon 5        V       ['related_track']       0.82
11      4kbj5MwxO1bq9wjT5g9HaA  Shut Up and Dance       WALK THE MOON   TALKING IS HARD         ['related_track']       0.8
12      5KwNEhNRmcARfqdodCQQX8  Borrowed Time   Scissor Sisters         Scissor Sisters (Deluxe)        ['similar']     0.78
13      2Foc5Q5nqNiosCNqttzHof  Get Lucky (Radio Edit) [feat. Pharrell Williams and Nile Rodgers]       Daft Punk       Get Lucky (Radio Edit) [feat. Pharrell Williams and Nile Rodgers]       ['related_track']       0.77
14      5WBT8hjs92lQvzynqB2UfF  Like I Love You         Justin Timberlake       Like I Love You         ['related_artist']      0.76
15      5jE48hhRu8E6zBDPRSkEq7  All About That Bass     Meghan Trainor  Title (Deluxe)  ['related_track']       0.72
16      6Dmnk60OIdajUCnJw5JmhZ  HEADHIGH        Doja Cat        Scarlet 2 CLAUDE        ['similar']     0.71
17      40EqrQIeP09f2Z1Xnghki5  Love Me Down    Jason Derulo    Everything Is 4         ['related_artist']      0.7
18      1jdY24jJZWYVhLTQSVWQ8z  Blurred Lines   Pharrell Williams       Blurred Lines   ['related_track']       0.67
19      3x8iDlzufI5bf9yyvc4Onz  Dance Monkey    Tones And I     100 Greatest Party Songs        ['related_track']       0.65
20      4VUY48t3Ntkx2H45jSPVwc  Je ne sais pas pourquoi - Moi non plus Mix      Kylie Minogue   Je ne sais pas pourquoi / Made in Heaven        ['similar']     0.64

Query used time: 0.226s
```

![image.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/00be618e-f460-41ec-87e6-2774b0409fd7.png)

**分析**

*   《Sis puella magica!》关联到同动漫的其它歌曲《Desiderium》《Noi!》这个并不意外，因为很多用户就是按这样的规律整理歌单
    
*   《Uptown Funk》这种律动感很强的音乐能匹配到《CAN'T STOP THE FEELING!》与《Happy》等歌曲说明related\_track这路推荐非常成功，说明多个用户都捕捉到“强律动”这个特征，并以此规律创建的歌单
    

## Embeat v1.0：正式上线

**数据库优化**

*   体积优化：4500万条数据，未创建索引20.7GB，创建索引后约37GB，处于完全可接受的范围
    
*   内存优化：由于track\_name (str)与 artist\_name (str)的索引放到硬盘上，数据库启动后，内存占用内存占用无HNSW约9GB，有HNSW约17GB，可以接受
    
*   检索速度优化：多路回召，从query\_track\_id (str)进，到final\_result (list)出，速度控制在30~100ms区间，非常不错
    

**API封装**

*   使用Python FastAPI对接口进行封装，输入track\_id或track\_name + artist\_name，以及可选参数top\_k，输出推荐列表
    
*   这里需要做一层翻译，将query的track\_name + artist\_name转成track\_id。由于之前Qdrant数据库以及做过分词处理，可以进行模糊搜索，但大部分时候这种模糊搜索仍不够智能，如果我想听《周杰伦 - 太阳之子》，而数据库记录的是《Jay Chou - Son of the Sun》，这是一定会匹配失败的
    
*   我的处理方案是，调用Spotify的开发者API，通过\`track:"太阳之子" artist:"周杰伦"\`作为query，利用Spotify强大的别名搜索功能，联网找到query对应的track\_id
    
*   另外还设置了一套回退方案，由于Spotify严格限制API的调用频率，当请求数过多出现429时，从Tunebat爬虫获取track\_id。实测过Tunebat背后调用的也是Spotify的开发者API
    
*   v1.1新增了ISRC查询功能，可以通过MusicBrainz先根据track\_name + artist\_name转成对应的ISRC，数据库支持通过ISRC查询
    

**接入系统**

*   将Embeat接入我的免费音乐网站GD音乐台（https://music.gdstudio.xyz）
    
*   新增“心动模式”：播放器底下可选择播放模式，顺序播放、单曲循环、随机播放，以及心动模式。用户点击一首歌曲开始播放后，之后听到的歌曲都以这首种子曲目为中心进行推荐播放。若用户重新点击另一首歌曲，会生成新的推荐歌单
    
*   V1.0先以最小功能进行发行，V2.0将会有大量好玩的新功能
    

![embeat_entry.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/40a5c041-67c1-4468-8161-fe2451c9e41a.png)

**数据库二次优化**

*   由于甲骨文在2026.8.18回收了4核24G内存的ARM服务器，Embeat的向量数据库被迫迁移到其它服务器上。由于财力有限，我只买了个2核4G内存20GB存储空间的VPS，因此需要思考如何将数据库做进一步剪枝
    
*   首先想到的是popularity，之前是按popularity >= 1做筛选，共45M条数据，占用约17GB内存。后边直接大刀阔斧砍到popularity >= 10，共12.3M条数据，量级至四分之一，然而此时数据库仍需要4~5GB内存
    
*   接下来对payload indexing动刀。payload指的是比如artist\_idx、artist\_genre\_idx、popularity这些常驻内存的纯数值数据。将其转到磁盘上，需要时再读取到内存，配合一些参数优化，再节省一部分内存占用
    
*   继续对vector动刀。vector指的是每条记录对应的64维向量。由于有HNSW图索引，而取向量或向量比对一般都在1000条记录以内，将这部分迁移到磁盘实测会减慢一点query速度，但大幅降低内存占用
    
*   剩下的HNSW就不再动刀了，这部分一定要常驻内存，而且之前设的HnswConfigDiff.m=8已经够低了，几乎没有优化空间
    
*   另外我需要ISRC作为检索字段，这样我能通过Spotify的track\_id做检索，也能通过歌曲的isrc做检索，因此将isrc同样做payload indexing了。由于这部分放在硬盘上，并不增加内存
    
*   这里记录一个踩过的坑，Qdrant的Optimizer有个default\_segment\_number参数，默认值为0，代表Qdrant根据CPU核数自动选择数据库分段数。这就导致我在开发机创建的数据库分段数为8，拷贝到VPS上由于2核不同于8，VPS上的Qdrant进程会强制开始合并分段，拉高CPU占用与磁盘IO。解决方案一个是在创建数据库时固定default\_segment\_number=8或default\_segment\_number=2，或者VPS上设置Qdrant的max\_optimization\_threads=0，即永不触发优化操作。这里我选择前者
    
*   最后导出的mini版数据库9.3GB，压缩后3.7GB，运行时占用2~2.5GB内存，完美
    

## Embeat v1.1：更多流派标签实验

**v1.0存在的问题**

*   每个艺人只有一个artist\_genre\_idx，由v0.4的引力模型结合EveryNoise二维坐标计算得出，代表最符合该艺人的流派。目前艺人的全部作品共用这一个标签，但艺人一生的创作风格会迁移，单一标签无法表达其所有作品
    
*   Embeat推荐系统强依赖这个标签，多路召回中大量使用此标签作为filter，因此标签质量很大程度影响了推荐深度
    
*   上文说过，齐豫的artist\_genre\_idx对应的标签为“华语经典”，用于推荐《橄榄树》这类歌曲没问题。但如果用户的种子歌曲是齐豫的《準提神咒》佛教音乐，而返回结果出现大量蔡琴、潘越云等作品，就会显得很突兀
    
*   为什么不用单曲级的track\_genre\_idx？因为Spotify接口不返回，几乎没有任何途径能直接获得单曲级的流派标签
    

**如何获得track\_genre\_idx**

*   方案一（v1.0使用方案）：参考本文 Embeat v0.4 -> Qdrant端的处理。简单说就是，根据声学相似邻居的artist\_genre\_idx，来重新推断track\_genre\_idx
    
*   方案二：为每个流派计算一个genre\_mean\_embedding，然后计算种子歌曲到艺人多流派的genre\_mean\_embedding距离，取距离最近的流派作为track\_genre\_idx
    
*   方案三：使用Track2Vec模型参与投票，重新推断track\_genre\_idx
    

**方案一**

*   线上实测下来，大量出现track\_genre\_idx==artist\_genre\_idx的情况。即便真出现track\_genre\_idx!=artist\_genre\_idx，错误率也有点高。最后索性使track\_genre\_idx=artist\_genre\_idx，即将艺人流派标签作为单曲流派标签
    
*   为什么错误率会有点高？其实方案一就是方案二的变体，我将在下文具体剖析原因
    

**方案二**

*   整体思路是这样的：
    
    *   获取6291个微流派的平均声学向量genre\_mean\_embedding：EveryNoise流派子页面 → 排除多流派艺人 → 取preview\_track\_id → 在45M数据集中定位这首demo歌曲 → EmbeatMLP前向计算声学向量 → 多向量取平均
        
    *   对于每一首种子曲目，获取艺人的多流派标签，如果是单流派，使track\_genre\_idx=artist\_genre\_idx，跳过后续计算
        
    *   如果是多流派标签，首先计算种子曲目的声学向量，然后比对种子曲目声学向量与艺人几个流派的声学向量余弦距离，取距离最近的流派作为track\_genre\_idx
        
*   这套理论成立的前提是，微流派的声学特征分布与Spotify的声学特征分布有强相关性。如，华语经典可能原声性高，情绪偏沉稳；欧美流行可能原声性低，情绪偏兴奋。但这只是我的猜测，事实是否如此，需要实验验证
    
*   首先统计6291个微流派，在去除多流派艺人后，单流派艺人数量的分布情况（单流派艺人数量 -> 微流派数量）：
    

```shell
count >= 1: 6272 (99.7%)
count >= 10: 5830 (92.7%)
count >= 30: 3197 (50.8%)
count >= 50: 1563 (24.8%)
count >= 100: 368 (5.8%)
count >= 300: 9 (0.1%)
count == 0: 20 (3.2%，说明这20个微流派没有单流派艺人，将这20个微流派视为<unk>)
```

*   这里需要一份验证集，我采用EveryNoise首页的preview\_track，这些是它的创作者Glenn McDonald选择的“最能代表这个微流派风格”的精选曲，计算这5830（count >= 10）首精选曲的EmbeatMLP embedding，打成一个HF dataset数据集
    
*   然后统计流派类内样本平均距离 vs 到最近流派中心距离：
    
    *   一首歌到自己流派中心：余弦距离0.412
        
    *   两个流派中心之间：余弦距离0.032
        
    *   根据这组数据，其实已经观察到一个危险信号。即，随便一首歌曲，它离自己流派中心的距离，比流派跟流派间距离还远。换句话说，微流派的内部声学特征分布过散，与Spotify的声学特征分布并无多大联系
        
*   虽然数据已经跑出一些不好的结果，但总归要实际验证下，这里需要一份可用的验证集：
    
    *   任务是这样的，给定一首歌 + 一个候选流派集合（真实流派 + K个干扰流派），最近中心点能否选回真实流派？
        
    *   我采用EveryNoise首页的preview\_track，这些是它的创作者Glenn McDonald选择的“最能代表这个微流派风格”的精选曲，计算这6921首精选曲的EmbeatMLP embedding，打成一个HF dataset数据集
        
    *   当然，这些数据需要做一点限制：干扰项应该选用EveryNoise二维图上的邻居，毕竟“华语经典 vs 挪威黑金属”取得胜利，并不能说明什么问题。图上近邻才对应真实场景，艺人身上共现的流派本来就是图上相邻的，这是引力模型的前提假设
        
    *   在这种干扰限制下，K=2时，正确率为58.9%，K=5正确率40.9%，K=10正确率28.8%。这个成绩可以说是非常糟糕的，两个干扰项下，正确率就折半了
        
    *   这里我放宽了判定标准，如果不对干扰项做EveryNoise二维图上邻居的限定，邻居随机采样，K=2时正确率78.3%，K=5正确率62.6%，K=10正确率49.9%。这个成绩看起来还可以，毕竟是放宽了限制，但事实真如此吗？
        
*   使用齐豫的例子做实际案例研究：
    
    *   种子曲目我选了两首：《橄榄树》-> 华语经典；《準提神咒》-> 佛经
        
    *   艺人齐豫身上的流派有三个标签：华语经典、华语流行、佛经
        
    *   这里我手动撤掉了“华语流行”这个标签，一方面华语流行跟华语经典较为接近，难以区分。另一方面华语流行的单流派艺人样本数过少，即“华语流行”这个微流派由大量多流派艺人构成，过于综合了，流派平均声学向量置信度不高
        
    *   首先计算类间距离：EveryNoise 2D-map上，华语经典与佛经的欧氏距离是0.104，流派平均声学向量的余弦距离是0.295，说明这两个微流派距离并不算近，即风格有差异
        
    *   然后计算《橄榄树》到两个流派的声学距离，到华语经典是0.312，到佛经是0.216；计算《準提神咒》到两个流派的声学距离，到华语经典是0.145，到佛经是0.273
        
    *   这个结果令我非常意外：方案二完全颠倒了正确结果，认为《橄榄树》是佛经，《準提神咒》是华语经典，而且这个置信度还不低。但为什么呢？
        
    *   从45M数据集中拉取这两首歌的Spotify Audio Features，会发现《準提神咒》的valence、energy、danceability、acousticness都比《橄榄树》更高，而且《準提神咒》是大调，《橄榄树》是小调。换句话说，《準提神咒》在每一维上都比《橄榄树》更明亮，更有律动。这在音乐上说得通，佛教音乐通常有固定节拍、器乐伴奏、大调式吟诵；而《橄榄树》是慢速小调民谣，安静得多
        
    *   我们来看下《橄榄树》的最近流派：以色列民谣、澳洲氛围、法罗群岛民谣、瑞士爵士、哈西德派犹太吟唱，这些在文化上毫无关联，声学上全是“安静、原声、慢”。再看看《準提神咒》的最近流派：韩国敬拜、粤语敬拜、犹太女声、印尼敬拜、拉丁基督教，EmbeatMLP模型的确抓到了“这是宗教吟唱”这个信号，但“佛经”排到了239位，说明“佛经”在声学上与这些宗教吟唱区别较大。模型学到的佛经是“安静 + 高原声 + 低能量”，于是《橄榄树》比《準提神咒》更符合。
        
    *   判定完全遵循了模型学到的规律，规律本身也是真实的，只是流派并非完全以声学相似聚合，更多的或许与文化归属相挂钩。如周杰伦的《太阳之子》与周深的《大鱼》都属于华语流行，但前者明亮后者舒缓，声学特征很不一样，但二者共享华语流行流派标签。因此，我们假定的前提“微流派的声学特征分布与Spotify的声学特征分布有强相关性”是错误的，方案二不成立
        
    *   方案一与方案二大体思路接近，既然结论是“微流派的声学特征分布与Spotify的声学特征分布没有联系”，那么投票人换成相关多流派艺人的作品，一样会带来错误信号
        
*   最终否决方案二落地
    

**方案三**

*   既然流派是文化标签，就该用文化数据判定。歌单共现是用户行为数据，是一种隐性的文化层面数据体现
    
*   整体思路是这样的。对于任意一首种子曲目：
    
    *   取Track2Vec近邻，按相似度降序
        
    *   排除同artist / 同album / 无流派标签的邻居
        
    *   邻居的 artist\_genre\_idx落在种子曲目的艺人流派列表artist\_genres内的才计票
        
    *   权重按排名衰减
        
    *   top1显著超过top2才判定track\_genre\_idx=top1\_genre\_idx（默认值EMBEAT\_TRACK\_GENRE\_SCORE\_RATIO=2.0，意思是top1得分要达到top2得分的2倍以上），否则回退到artist\_genre\_idx
        
*   先分析下这个方案的优缺点：
    
    *   首先Track2Vec训练自Spotify的热门歌单，经过我的数据清洗与筛选，最终保留剩109万首歌，占比原数据集4500万，仅有2.4%的歌曲存在于模型词表中
        
    *   但原数据集的分布，或者说歌曲的分布，按popularity排序来看是长尾的，因此这109万首歌其实代表了相对热门的歌曲
        
    *   换句话说，热门歌曲的流派推算会更精准，而冷门歌曲的流派推算只能靠艺人的流派标签（回退策略），相对就没那么精准了
        
*   验证集：验证集可以直接复用方案二实验的验证集，这样就拿到了6291首歌的gt\_genre\_idx。实际经过筛选后，验证集是5350首歌
    
*   实验方案：同样复用方案二的实验设计，用2D-map上相近的K个邻居来判断Track2Vec的改写准确率
    
*   结果分析（以下分析基于验证集，非45M曲库分布，K取值为5）：
    
    *   覆盖率46.2%，即5350样本验证集将近一半的歌曲不在Track2Vec的109万词表内。
        
    *   改写率13.1%，即5350验证集中多流派艺人（3256样本）中真正改变结果的比例，track\_genre\_idx != artist\_genre\_idx
        
    *   改写准确率87.4%，即3256多流派艺人样本中发生流派改写的426样本，有87%改对了
        
    *   在多流派艺人场景下（3256样本），方案二准确率为35.7%，而方案三准确率飙升到91.3%
        
    *   具体到两个方案的错误分析，两者结果不一致占52.5%，其中96%的情况是方案三正确
        
*   方案三整体来说是个纯受益的工程手段。这里举个具体例子感受下：
    
    *   种子曲目为齐豫的《橄榄树》，齐豫的流派标签为：华语经典、华语流行、佛经
        
    *   根据方案二，算出《橄榄树》的流派邻居为：以色列民谣、澳洲氛围、法罗群岛民谣、瑞士爵士、哈西德派犹太吟唱
        
    *   根据方案三，算出《橄榄树》的流派邻居为：华语经典、台湾校园民谣、粤语经典、华语流行、台湾唱作人
        
    *   其中，top1华语经典得分13.19，top2华语流行得分8.78，由于EMBEAT\_TRACK\_GENRE\_SCORE\_RATIO=2.0，top1并未两倍超过top2，执行回退策略
        
    *   但是，齐豫的艺人流派根据v0.4的流派引力模型计算后就是华语经典，因此不管回不回退，《橄榄树》在方案三策略下，它的track\_genre\_idx就是华语经典，因此是纯受益。
        
    *   为什么EMBEAT\_TRACK\_GENRE\_SCORE\_RATIO设置2.0比较高？因为流派引力模型本身计算质量就不差，只有当歌单共现信号非常强时，才执行单曲流派改写操作，这样系统的稳定性更高，宁可漏判也不要错判
        
*   高达91.3%的准确率是有代价的，那就是验证集中一半的歌曲不在Track2Vec模型词表中。这意味着一半的歌曲无法享受来自用户共现行为数据投票选取单曲级流派，只能回退到歌手流派。换算成45M全曲库角度来看，那就是最需要被流派改写的冷门歌曲，很可能得不到改写。这里同样举个具体例子感受下：
    
    *   因为齐豫大部分的作品对“华语经典”这个流派的影响力更大，因此齐豫的艺人流派标签是华语经典，《橄榄树》、《欢颜》等作品即便没有方案三的改写，都会用“华语经典”这个标签做推荐
        
    *   而最需要方案三改写的《準提神咒》，查询Track2Vec的词表，并未找到这首歌，因为它相对冷门。这就导致了《準提神咒》仍会使用“华语经典”这个标签做推荐，佛教音乐混杂大量黄莺莺、张学友的作品，降低了推荐质量
        
*   最终决定上线方案三
    

**方案三Demo**

*   方案三并非花瓶，这里举个从方案三的受益例子，种子曲目来自波兰的电影配乐大师Abel Korzeniowski《Dance For Me Wallis》
    
*   Abel Korzeniowski有两个流派标签：影视原声、氛围音乐，其中流派引力模型将他归为“影视原声”。这个归类是正确的，Abel Korzeniowski的音乐从功能性来说确实服从于电影配乐，与Hanz Zimmer、John Williams属于同种类别。但Abel Korzeniowski从音乐风格角度来说，他又是新古典的代表，尤其擅长氛围渲染，严肃音乐的乐理基底结合毛毡钢琴、环境录音等现代混音技术，具有很强的电影感与叙事张力
    
*   影视原声vs氛围音乐对于Abel Korzeniowski这位配乐大师来说都正确，那怎么办呢？这里看下以artist\_genre\_idx（soundtrack）为筛选条件的结果：
    

```shell
Query track_id: 1vWtTis3VRkkUp8qST4jY5
Query track info: Dance For Me Wallis - Abel Korzeniowski
Query artist genres: ['soundtrack', 'compositional ambient']
-> Find query record used time: 5ms
-> Related track recall used time: 163ms
-> Similar recall used time: 120ms
-> Popular recall used time: 57ms
-> Same artist recall used time: 18ms
-> Related artist recall used time: 24ms
-> Re-ranking used time: 1ms
Result artist genres: ['latin soundtrack', 'post-minimalism', 'epicore', 'orchestral soundtrack', 'british soundtrack', 'compositional ambient', 'neo-classical', 'soundtrack']
======= Top 20 items =======
index   track_id                track_name      artist_name     album_name      sources         score
1       3gxwWtR114M0fzmSPNWdfI  Charms  Abel Korzeniowski       W.E. - Music From The Motion Picture    ['same_artist', 'related_track']        1.0
2       06IHJXjHrz1THZG2dPE1jq  Ethan's Waltz   Abel Korzeniowski       Penny Dreadful: Seasons 2 & 3 (Music From The Showtime Original Series)         ['same_artist', 'related_track']        1.0
3       5ay0cvjtVAbMLOe8gTadFh  A Model of the Universe         Jóhann Jóhannsson       The Theory of Everything (Original Motion Picture Soundtrack)   ['popular', 'related_track']    1.0
4       4BMHp3DkI8VLsuB9Kr0pzu  Primavera       Ludovico Einaudi        Divenire (Deluxe Edition)       ['related_track']       0.94
5       2NGhKPZdZk2pPZinWphTzh  November        Max Richter     Voyager - Essential Max Richter         ['related_track']       0.92
6       3yHrZ0f21yKyzG0ox65nB0  Obliviate       Alexandre Desplat       Harry Potter and the Deathly Hallows, Pt. 1 (Original Motion Picture Soundtrack)        ['related_artist']      0.9
7       7bJ6xGa3RG2dQgNiK10uAv  Chalkboard      Jóhann Jóhannsson       The Theory of Everything (Original Motion Picture Soundtrack)   ['related_track']       0.9
8       6z720DAWxYJL5SacdZLlEN  Für Elise - Reimagined  Alexander Joseph        Für Elise - Reimagined  ['related_track']       0.88
9       7xToWWonEBX2NbGUEvLrTh  Edith's Theme   Fernando Velázquez      Crimson Peak (Original Motion Picture Soundtrack)       ['related_artist']      0.87
10      7yUukIX492jvLR85xYQkvc  Run     Ludovico Einaudi        In A Time Lapse         ['related_track']       0.86
11      3GyG4FmNAbeRa71nfOMocX  Double Trouble - From "Harry Potter And The Prisoner of Azkaban"        John Williams   The Essential Harry Potter Film Music Collection        ['similar']     0.85
12      7deA9LoAMx6C9JRbASnKYs  Liz On Top Of the World (Arr. for flute and piano by Emily Mussio Marquez)      Dario Marianelli        Liz On Top Of the World         ['related_artist']      0.84
13      20vEB3It4xYzKHlMecu6X1  Table For Two   Abel Korzeniowski       Nocturnal Animals (Original Motion Picture Soundtrack)  ['popular', 'related_track']    1.0
14      2dHnIe0JsqshUnyzcMMOhW  Domestic Pressures      Jóhann Jóhannsson       The Theory of Everything (Original Motion Picture Soundtrack)   ['related_track']       0.84
15      3yHB9YGaIJkDW4bkkYmrnx  Team Steals the Cargo   Kevin Kiner     Star Wars Rebels: Season Two (Original Soundtrack)      ['similar']     0.83
16      61o9ek0A26KARnXBPssDv3  Life    Ludovico Einaudi        In A Time Lapse         ['related_track']       0.82
17      4cKZeIpjfoa89P53WAegZC  Insertion       Hans Zimmer     Crysis 2        ['similar']     0.81
18      2MrhzSaEzyk4WwzGqR88UC  Never Been Kissed       Craig Armstrong         Far from the Madding Crowd (Original Motion Picture Soundtrack)         ['related_artist']      0.81
19      5gC2gJLfv8VZjePQXWZh7j  The Secret History      Kerry Muzzey    Kerry Muzzey: The Architect     ['related_track']       0.8
20      1mci7LNBsCBn8JcGAQQDow  Running on Raindrops    James Newton Howard     Raya e l'Ultimo Drago (Colonna Sonora Originale)        ['similar']     0.79

Query used time: 0.391s
```

*   以track\_genre\_idx（compositional ambient）为筛选条件的结果：
    

```shell
Query track_id: 1vWtTis3VRkkUp8qST4jY5
Query track info: Dance For Me Wallis - Abel Korzeniowski
Query artist genres: ['soundtrack', 'compositional ambient']
-> Find query record used time: 3ms
-> Related track recall used time: 130ms
-> [UPDATED] Query track genre: soundtrack -> compositional ambient
-> Similar recall used time: 121ms
-> Popular recall used time: 32ms
-> Same artist recall used time: 3ms
-> Related artist recall used time: 3ms
-> Re-ranking used time: 1ms
Result artist genres: ['latin soundtrack', 'post-minimalism', 'epicore', 'orchestral soundtrack', 'british soundtrack', 'compositional ambient', 'neo-classical', 'soundtrack']
======= Top 20 items =======
index   track_id                track_name      artist_name     album_name      sources         score
1       3gxwWtR114M0fzmSPNWdfI  Charms  Abel Korzeniowski       W.E. - Music From The Motion Picture    ['same_artist', 'related_track']        1.0
2       61o9ek0A26KARnXBPssDv3  Life    Ludovico Einaudi        In A Time Lapse         ['popular', 'related_track']    1.0
3       20vEB3It4xYzKHlMecu6X1  Table For Two   Abel Korzeniowski       Nocturnal Animals (Original Motion Picture Soundtrack)  ['related_track']       0.98
4       06IHJXjHrz1THZG2dPE1jq  Ethan's Waltz   Abel Korzeniowski       Penny Dreadful: Seasons 2 & 3 (Music From The Showtime Original Series)         ['related_track']       0.96
5       1BncfTJAWxrsxyT9culBrj  Experience      Ludovico Einaudi        In A Time Lapse         ['popular', 'related_track']    1.0
6       4BMHp3DkI8VLsuB9Kr0pzu  Primavera       Ludovico Einaudi        Divenire (Deluxe Edition)       ['related_track']       0.94
7       2NGhKPZdZk2pPZinWphTzh  November        Max Richter     Voyager - Essential Max Richter         ['related_track']       0.92
8       3yHrZ0f21yKyzG0ox65nB0  Obliviate       Alexandre Desplat       Harry Potter and the Deathly Hallows, Pt. 1 (Original Motion Picture Soundtrack)        ['related_artist']      0.9
9       7bJ6xGa3RG2dQgNiK10uAv  Chalkboard      Jóhann Jóhannsson       The Theory of Everything (Original Motion Picture Soundtrack)   ['related_track']       0.9
10      6z720DAWxYJL5SacdZLlEN  Für Elise - Reimagined  Alexander Joseph        Für Elise - Reimagined  ['related_track']       0.88
11      7xToWWonEBX2NbGUEvLrTh  Edith's Theme   Fernando Velázquez      Crimson Peak (Original Motion Picture Soundtrack)       ['related_artist']      0.87
12      5fj1seZKHx6YZ0wrZ2dMW1  Non Fiction – Piano Concerto In Four Movements: Ib. Animato     Hania Rani      Non Fiction – Piano Concerto In Four Movements: I. Sonore - Animato - Meno Mosso        ['similar']     0.85
13      7deA9LoAMx6C9JRbASnKYs  Liz On Top Of the World (Arr. for flute and piano by Emily Mussio Marquez)      Dario Marianelli        Liz On Top Of the World         ['related_artist']      0.84
14      2dHnIe0JsqshUnyzcMMOhW  Domestic Pressures      Jóhann Jóhannsson       The Theory of Everything (Original Motion Picture Soundtrack)   ['related_track']       0.84
15      554hNwr4zSkRKvt7PWizHc  Only Strings And Their Supports Remain  Dustin O'Halloran       Invisible Cities        ['similar']     0.83
16      1j2nfssRQ0OPlL5ghqNZNH  Thunderstorm    René Aubry      The Snail and the Whale (Original Score)        ['similar']     0.81
17      2MrhzSaEzyk4WwzGqR88UC  Never Been Kissed       Craig Armstrong         Far from the Madding Crowd (Original Motion Picture Soundtrack)         ['related_artist']      0.81
18      5gC2gJLfv8VZjePQXWZh7j  The Secret History      Kerry Muzzey    Kerry Muzzey: The Architect     ['related_track']       0.8
19      06Gdnnc9bP6sTO24vCv0ko  What Happened   Peter Gregson   A Little Chaos (Original Soundtrack Album)      ['similar']     0.79
20      7wsOdebEclWBr80KucGiYn  Wong Chia Chi's Theme   Alexandre Desplat       Lust, Caution   ['related_artist']      0.78

Query used time: 0.297s
```

*   可以发现，第二份明显比第一份更好。我们先分析种子曲目《Dance For Me Wallis》，这是一首弦乐主导小编制、小调四四拍、整体慢速但带大量分解和弦短音、典型的极简主义循环演进的作品。除去重叠的推荐曲目，第一份中出现《Double Trouble》是HP《阿兹卡班的囚徒》里的合唱曲，急促诙谐、有人声；《Insertion》是游戏配乐，电子+打击乐驱动，季默叔的宏大叙事风格。而第二份中出现的Max Richter《November》、鲁叔的《Primavera》显然是新古典的作品，短音循环演进，与重叠的推荐曲目比如《Charms》、《Life》属于同一风格
    
*   低情商说法：第一份有割裂感，第二份风格延续性更好。高情商说法：第一份部分歌曲探索了新风格，带给用户更多惊喜。至于怎么选，我肯定选第二份，毕竟Embeat的终极目的是找到风格最接近种子曲目风格的相似歌曲，不是随机电台
    

## 评估：Embeat vs 网易云

**摘要**

*   先确定评估功能范围是item-to-item的推荐任务。给定一首种子歌曲，Embeat与网易云分别推荐5首相似推荐，评估谁的推荐质量更高
    
*   为什么是5首？因为网易云在相似歌曲任务上，最多只能输出5首，因此Embeat也限定同样输出5首
    
*   谁来评估？大模型LLM评估，分别选用Claude Opus 5、GPT 5.6 Sol、Gemini 3.1 Pro、Kimi K3
    
*   种子歌曲来源？EveryNoise将世界上的音乐分成6000+个微流派，并给每个微流派一首代表音乐。我按地区比例（中:日:韩:其它=44:23:14:76），随机挑选157首歌曲（流派），代表每个地区的不同风格
    
*   限定评估范围？在中日韩+世界音乐这个领域内，Embeat与网易云的item-to-item能力对比
    
*   结果如何？在四个评委都能评价的91条核心样本上，Embeat的胜率是89%~96%
    

**评估方案的废案**

*   摘要中其实已经大致讲清楚了我的整个评估方案。但为什么是这个方案？首先与音乐推荐系统相关的“歌单留一法”，即，找好几份歌单（华语经典、欧美流行），然后随机留一首作为种子歌曲，看看两个推荐系统（Embeat、网易云）的推荐结果能命中多少条歌单中的其它歌曲，记录recall@k值
    
*   “歌单留一法”对于这个两两比对的评估任务来说，其实有几个致命问题：1、没有中立的歌单来源。来自Spotify或其它欧美数据集，那大概率Embeat赢。来自网易云或中文数据集，代表国人的听歌习惯，结果可能就反转了；2、两边的曲库不同。如果做歌单留一法，事实上需要两份歌单。一份是以netease\_track\_id为主键的歌单，另一份是以spotify\_track\_id为主键的歌单，两份歌单指向同样的歌曲。但我们知道，两边曲库不一定恒等，有些歌曲可能只有一边有而另一边没有；3、网易云侧只能吐出5首歌曲，recall@k的细粒度不足
    
*   在这套评估方案之前，其实已经对Embeat v0.6做过一版评估，简单说就是：让LLM（窗口A）按风格自行选择10首种子歌曲，然后分别调用网易云与Embeat的推荐接口，各自拿到5首推荐歌曲。再让同型号的LLM（窗口B）同时判断，这10份AB评估结果，哪份更好？结果是，Claude Sonnet 4.6认为Embeat胜率80%，Gemini 3.5 Flash胜率90%，GPT 5.5胜率60%。但这份数据结果缺乏说服力
    
*   第一版评估方案其实就是第二版的雏形，只是设计过于简陋：1、样本太少，每个LLM只评10首，方差过大；2、LLM评委自己选的10首歌曲，虽然指定要热门冷门分布均匀，但LLM倾向于选较热门的歌曲，这与LLM的训练数据有关，很难评估真正的低热度长尾歌曲；3、未解决元数据泄露问题。Embeat的歌曲数据大量出现拼音、英文的拼写，而网易云是纯中文，LLM在两三轮比对中其实就已经知道谁是网易云谁是Embeat了，这是一个信号的泄露
    
**V2评估方案**
    
*   为了让Embeat能“光明正大”地在item-to-item这个任务打赢网易云，我全面重设计了V2评估方案。
    
*   总体方案：类似V1评估方案，让LLM为A、B每一条评估结果打分，并从A、B中挑选更好的一组，解释为什么
    
*   数据来源：来源EveryNoise主页的Demo曲目，以流派为粒度单位。中、日韩、其它地区，按比例大致选择1:1:2。地区内的流派选择，则随机采样。最后选定176首种子歌曲。
    
*   数据筛选：需保证种子歌曲既存在于Embeat曲库，又存在于网易云曲库，确保能拿到spotify\_track\_id与netease\_track\_id且版本完全对应。分别调用网易云与Embeat的推荐接口，存储返回结果
    
*   结果清洗：
    
    *   规则1：Spotify倾向于返回带罗马音或英文的歌曲歌手名（例如：《Jay Chou - Son of the Sun》），而网易云则返回另一种风格（例如：《周杰伦 - 太阳之子》），需要统一风格，避免LLM裁判从元数据中认出推荐引擎来源。这里我使用MusicBrainz数据库作为中间人，种子歌曲、Embeat推荐结果、网易云推荐结果均使用MB做清洗，保存MusicBrainz提供的“歌曲名 - 歌手名”。当然，如果MB上没有这条记录，比如网易云上的翻唱曲、原创曲，则回退到原始值
        
    *   规则2：同时，需要筛除掉网易云侧推荐不足5条的种子歌曲（Embeat没这个问题），网易云有10.8%的种子无法返回完整推荐，因此176首种子歌曲剩157首
        
    *   规则3：打散157份数据，随机分配A、B标签，并通过另一个文件记录A、B标签的映射答案
        
*   对于157条核心数据，每个LLM跑157轮评估：
    
    *   评委LLM能看到什么：id（用于反查AB映射答案）、query\_song（种子歌曲的歌曲名、歌手名）、recs\_a（引擎A的5条推荐结果）、recs\_b（引擎B的5条推荐结果）。评委LLM对与157首歌曲，每次新建会话，确保不会跨窗口识别元数据泄露信号
        
    *   评委LLM给出什么：id（复写一遍ID）、seed\_familiarity（是否认识种子歌曲，分known、artist-only、unknown）、a\_scores（A组的5条各自得分，1~5五级制）、b\_scores（5组的5条得分）、winner（LLM总体认为谁好，分a、b、tie）、confidence（对自己答案的信心，分low、medium、high）、reason（为什么这么评）
        
    *   评委LLM的特殊规则：1、当LLM不认识种子歌曲时，跳过这轮评估；2、当LLM不认识A、B列表里的某一首歌曲时，给-1分，之后有统计脚本筛除掉这部分数据；3、评分归评分，而winner是发挥LLM主观能动性的选择。例如，A列表给出大量4、5分的推荐，B列表只有一首5分推荐，其余4首给-1分。算均分的话B>A，但此时LLM会更倾向于选A作为winner，因为A的输出总体更稳定
        
*   拿到157条评估结果后，跑统计脚本，得到最终数据。脚本内容包括：反查A、B答案、计算两者均分、计算两者胜率、计算地区分布，等等
    

**结果展示**

*   数据清洗：
    
    *   EveryNoise微流派：6291
        
    *   在Embeat 45M数据库中可定位：5738
        
    *   5738中在网易云中有匹配项：4489
        
    *   按地区分布取样：176
        
    *   两侧都返回完整5条推荐：157
        
    *   华语 : 日语 : 韩语 : 其它 = 44:23:14:76
        
    *   华语只占28%，不是因为我想回避网易云的主场，而是EveryNoise的流派体系本身以世界音乐为主，而华语相关的微流派总共只有40~50个，我已经全部用上了
        
*   随机选一首中文曲，看看评估输入的数据：
    
    *   其中，A是网易云，B是Embeat
        
    *   每轮随机分配AB，答案仅自己可见
        
    
```shell
  {
    "id": "24886acb114d504b9c44d44f13c86d70",
    "query_song": "外婆的澎湖灣 - 潘安邦",
    "a_recs": [
      "喀秋莎 - 黑鸭子",
      "军港之夜 - 苏小明",
      "外婆的澎湖灣 - 潘安邦",
      "年輕人的心聲 - 潘安邦",
      "跟着感觉走 - 潘安邦"
    ],
    "b_recs": [
      "是否 - Julie Sue",
      "盼 - 潘安邦",
      "童年 - 羅大佑",
      "赤足走在田埂上 - 葉佳修",
      "今宵多珍重 - Fei Yu-ching"
    ]
  },
```
    
*   可用性评估：
    
    *   Embeat拥有100%的推荐结果返回率，甚至为了兼容网易云结果，下调top\_k至5
        
    *   网易云10首种子歌曲里就有1首推荐结果返回不满5条
        

|  | 完整返回5条 | 返回不足5条 | 完全无返回 |
| --- | --- | --- | --- |
| Embeat | 176 (100%) | 0 | 0 |
| 网易云 | 157 (89.2%) | 15 (8.5%) | 4 (2.3%) |

*   核心结果：
    
    *   总样本：157条
        
    *   核心子集：最关键的一组数据，即4个评委都认识的种子歌曲有91条，反映较热门的种子歌曲
        
    *   四个评委共同评过的91条中，84条被判定为winner一致，认可率达92.3%
        

| 评委 | 总样本胜率 | 核心子集胜率 | Embeat均分 | 网易云均分 |
| --- | --- | --- | --- | --- |
| Claude Opus 5 | 89.7% | 95.6% | 4.15 | 3.20 |
| GPT 5.6 Sol | 84.5% | 89.0% | 4.07 | 3.24 |
| Gemini 3.1 Pro | 94.7% | 94.5% | 4.14 | 3.13 |
| Kimi K3 | 83.6% | 94.5% | 4.02 | 3.23 |

*   按地区分类：
    
    *   华语44首，日语23首，韩语14首，其它地区76首
        
    *   中日韩 : 其它地区 ≈ 1:1
        

| 评委 | 华语 | 日语 | 韩语 | 其它地区 |
| --- | --- | --- | --- | --- |
| Claude Opus 5 | 31:6 (84%) | 19:0 (100%) | 12:1 (92%) | 60:7 (90%) |
| GPT 5.6 Sol | 29:7 (81%) | 15:1 (94%) | 9:2 (82%) | 56:10 (85%) |
| Gemini 3.1 Pro | 23:3 (88%) | 7:0 (100%) | 5:1 (83%) | 55:1 (98%) |
| Kimi K3 | 34:8 (81%) | 15:5 (75%) | 7:3 (70%) | 61:7 (90%) |

*   按热度分类：
    
    *   热度数据来源Embeat数据集，即Spotify标定的单曲热度标签
        
    *   热度不参与数据筛选环节，同样不提供给裁判LLM热度标签
        

| 热度档 | 样本数 | Embeat胜率 | 评委跳过率 |
| --- | --- | --- | --- |
| 头部 (≥0.45) | 31 | 90% | 3.2% |
| 中上 (0.30–0.45) | 46 | 92% | 11.4% |
| 中下 (0.18–0.30) | 46 | 87% | 30.4% |
| 长尾 (<0.18) | 34 | 77% | 32.4% |

*   网易云赢的那部分：
    
    *   显然，华语热门流派仍是网易云的主力领域
        
    
| 流派 | 语种 | 判网易云胜的评委数 |
| --- | --- | --- |
| c-pop | 华语 | 4/4 |
| classic cantopop | 华语 | 4/4 |
| vintage chinese pop | 华语 | 4/4 |
| cantonese opera | 华语 | 3/4 |
| chinese reggae | 华语 | 3/4 |
| k-pop | 韩语 | 3/4 |
| korean hardcore | 韩语 | 3/4 |
| classic greek pop | 其他 | 3/4 |
| trova | 其他 | 3/4 |
    
*   网易云输的那部分：
    
    *   网易云近五分之一的列表直接倒艺人曲库，回退策略简单粗暴，这是产品级的怠惰
        
    *   网易云在陌生流派领域（比如mountain dulcimer，山扬琴民乐）基本靠猜，只认识热门流派
        
    *   网易云存在年代漂移问题，比如种子是《Demi Lovato - Heart Attack》，网易云返回了一首恋与深空手游的OST歌曲
        
    *   网易云同样存在跨语种污染，比如种子歌曲是泰国摇滚I-Zax，混进来一首韩语歌曲
        
    *   这些失分项不能说明网易云的协同过滤模型能力差，而是当协同过滤失效时，回退的兜底策略不行
        
    
| 网易云同艺人席位 | 样本数 | Embeat胜率 |
| --- | --- | --- |
| 1（完全分散） | 75 | 90.9% |
| 3 | 19 | 71.0% |
| 5（全是同艺人） | 29 | 91.7% |
    
*   最终判决：
    
    *   在这157个跨语种、跨流派的测试样本上，四个独立的大模型评委一致认为Embeat更好
        
    *   可用性：Embeat在全部176条种子上都能返回完整推荐，网易云有10.8%失败，韩语档失败率达26%
        
    *   相关性：核心样本上，Embeat均分4.02~4.15，网易云3.13~3.24
        
    *   多样性：Embeat每条列表平均4.88个不同艺人，网易云3.57；网易云28%的列表被单一歌手占去4席以上
        
    *   稳健性：优势在四个热度档上都成立，头部热度档甚至高于长尾冷门档
        
    *   Embeat长处：核心优势在于风格精准度和艺人多样性的平衡，尤其在跨语言、跨文化的风格场景中优势突出
        
    *   Embeat短板：华语歌曲是网易云的明确优势区，协同过滤在有厚实行为数据的地方仍难以被替代
        
*   评估脚本与数据均开源到GitHub仓库中，可自行审查复核，保证评估结果透明
    

**主观对比与总结**

*   Embeat基于Spotify的声学数据与用户歌单数据进行训练，以数据为导向的话，其实不管模型结构如何设计，Embeat推荐结果接近Spotify也在情理之中。在通过与Spotify单曲推荐实际对比后发现，Embeat的很多track推荐结果与Spotify相似，且来源命中related\_track（即第五路输出：歌单协同过滤）；同时artist推荐结果与Spotify相似，且来源命中related\_artist（即第四路输出：相似歌手）
    
*   我试过网易云、QQ、汽水的推荐，它们在较热门的歌曲推荐表现均不错，背靠大量用户UGC数据的支持，使得输入热门歌曲A，能很容易挖掘到热门歌曲B。且A、B两首歌存在的社会联系捕捉，是声学模型做不到的事情，这是协同过滤的优势。但面对冷门歌曲，由于可能出现用户聆听数据的匮乏与艺人标签的缺失，这些系统在这种情况下表现极不稳定，甚至可能出现驴头不对马嘴的推荐，比如输入是世界民族音乐，推荐ACG二次元音乐
    
*   Embeat微流派机制的引入，使得其在冷门歌曲的推荐上表现得很稳定。通过给每个已知流派的歌手或是通过已知流派歌手衍生的相关冷门歌手，为200多万歌手（约占Spotify上2/3）指定6600+个流派标签中的一个微流派，在流派空间做歌手级别的聚类。这样当冷门歌曲无法通过协同过滤这一路获得相似推荐，还能在歌手自己的流派空间找邻居，不至于完全失焦
    
*   EmbeatMLP是一个基于对比学习设计的MLP双塔模型，因为设计初期就是为了适配VPS的低性能CPU推理，所以输出维度也很低，只有64。它的作用是将Spotify Audio Features各种声学参数（danceability、energy、valance...）编码成向量，这样可以通过向量数据库快速找到其声学邻居。EmbeatMLP是Embeat项目的基底，几乎每一路输出都会用到这份声学向量，深入参与筛选、粗排、精排等环节。这个小模型的具体意义是：
    
    *   通过微流派标签找到同流派歌手的所有歌曲，然后使用EmbeatMLP向量，找到声学相似度更高的输出（流派相似+声学相似）
        
    *   找同歌手所有歌曲，然后使用EmbeatMLP向量筛选声学相似度更高的输出（相同歌手+声学相似）
        
    *   找当前歌手的相关歌手的所有歌曲，使用EmbeatMLP向量筛选声学相似度更高的输出（相似歌手+声学相似）
        
    *   在新歌的处理上，通过Spotify接口能获得其Audio Features，然后将这些数据输入给EmbeatMLP做前向推理（纯CPU的VPS可实时处理），能得到新歌的声学embedding，具备扩展性
        

*   Embeat在设计初期并没有第五路输出歌单协同过滤。在种子歌曲为热门歌曲的推荐上，虽说风格是一致了，但总觉得缺了点东西，同一种风格的音乐容易感觉到腻，用当时AI的评价来说就是，“Embeat是个品味极高的风格筛选机器”，缺乏了人性的味道。通过训练第二个模型Track2Vec则很好地弥补了这一缺点，通过学习大量Spotify用户歌单，了解用户真正的听歌偏好，Embeat在做到风格统一的基础上，能够给出一些我们更期望的结果，比如听到MJ能联想到火星哥，听到周杰伦能联想到陈奕迅
    
*   至此，Embeat音乐推荐系统确切的定义是，Content-based的声学模型 + Collaborative Filtering的用户偏好模型 + 庞大干净的Metadata数据库。CB保证了听感风格相似，CF确保推荐的歌曲不会过于小众，数据库各种丰富的微流派标签保障冷门歌曲不失焦。协同过滤类系统在缺乏行为数据时容易失焦或无返回，Embeat可回退到歌手微流派空间找邻居，保证推荐不跑偏。这也是为什么Embeat作为个人开发推荐系统能在多个维度击败网易云这种企业级推荐系统的原因
    
*   当然我们下结论不要一棒子打死，网易云在本土音乐推荐上仍保持有优势，其协同过滤模型十分强大，值得学习借鉴。不能简单一句话说“Embeat打败了网易云”，而应该是：Embeat在item-to-item相似歌曲推荐任务上，大多数情况尤其世界音乐场景都优于网易云，差异化体现在冷门歌曲的稳定性
    

## 未披露的细节

**本章节内容**

*   这部分是我在开发GD音乐台应用时冒出的一些想法，更多的是工程实践上的内容，与Embeat核心系统关联度较低，所以不详细展开介绍
    
*   如果对以下内容感兴趣，可以B站私信或TG群讨论，我将在私聊中分享更多实现细节
    

**听歌助手EmbeatAgent**

*   Embeat音乐推荐系统的扩展应用，本质上是做一个Agent应用，用户输入自然语言文本，如”我想听一首陈奕迅，比较平静的歌曲“，系统通过意图分析与各种tool use，最终返回《陈奕迅 - 红玫瑰》等歌曲
    
*   目前GD音乐台已实现第一版，欢迎体验
    

![embeat_agent.png](https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/r4mlQ5berxwNXlxo/img/056e04ec-c6f8-49e1-8f2e-92a963365681.png)

**自动换源**

*   几乎所有音乐平台的推荐系统都与自家的音乐数据库强绑定，因此输入输出都很具体唯一，比如网易云相似歌曲接口的输入是网易云歌曲ID，输出是一组网易云歌曲ID，不存在转换问题
    
*   GD音乐台是一个聚合音乐平台，输入五花八门，可能来自网易云、酷我、Tidal、Spotify等10个平台，我自然无法去做10平台曲库映射到Spotify ID这种操作，因此需要做一些工程设计，将10平台歌曲的元数据转换到Spotify平台，通过Embeat拿到推荐后，再将Spotify元数据重新映射回其它平台，实现比如“输入网易云ID -> Embeat推荐 -> 输出网易云ID”这样的转换功能
    
*   经过三轮设计迭代，目前GD音乐台的自动换源系统已十分稳定，在热门与较冷门歌曲的匹配度上已高度一致。只要不是平台独占歌曲，基本都能找到匹配项
    

**新歌入库**

*   Embeat数据集数据库来源于Spotify泄露数据，数据源截止至2025年9月。那后续新歌数据应该如何入库？
    
*   方案一：Tunebat等第三方音乐数据网站仍能对接最新的Spotify Audio Features API。OK点到为止，具体操作方法不再展开
    
*   方案二：用Python的Essentia包，从原始音频计算推出BPM、调式、调性、节拍的离散数据；然后连续值填0.0，模型内部预留了缺省空值位；调用EmbeatMLP模型，做一次前向推理
    
*   其实大部分时候并不需要新歌入库，开源的代码提供了歌手级的回退策略。比如周杰伦的《太阳之子》是2026年的新专辑，搜不到怎么办？代码会计算周杰伦艺人生涯的所有歌曲的平均向量，给出艺人级别的相关推荐。结合相似艺人与歌单协同过滤，大多数情况下还挺准的
    

**Embeat Music（挖坑，制作中）**

*   基于Embeat推荐系统的安卓听歌APP，结合EmbeatAgent，全新超前的设计理念，带给用户新的听歌体验
    
*   将Embeat的item-to-item推荐机制扩展到user-to-item，同时确保用户数据隐私，用户对自己数据拥有100%的掌管权利
    
*   亲自操刀界面UI设计与用户交互逻辑，非模板化的AI一键生成
    
*   WIP，敬请期待！
    

## 关于我

**技能树**

*   AI算法工程师（语音方向）
    
*   CG动画设计师
    
*   业余编曲
    

**GitHub（GD Studio）**

[https://github.com/gdstudio-org](https://github.com/gdstudio-org)

**B站（GD-Studio）**

[https://space.bilibili.com/13715770](https://space.bilibili.com/13715770)

**TG群（gdstudio\_music）**

[https://t.me/gdstudio\_music](https://t.me/gdstudio_music)

GD Studio

2026.09.16