给 1400 首歌做语义搜索:一个 QQ 机器人里的曲名检索设计

2265 字
11 分钟
给 1400 首歌做语义搜索:一个 QQ 机器人里的曲名检索设计

这篇讲它在曲名检索上的设计:字符串三层 + 向量兜底的混合架构,以及为什么我们做了向量检索,却没用任何向量数据库。

一、玩家搜歌的方式有多野#

舞萌的曲库是个重灾区:

  • 曲名本身是日文:ハッピーシンセサイザ
  • 玩家会打简体:千本樱(原曲是 千本桜
  • 会打罗马音:senbonzakurahappy synthesizer
  • 会打拼音:qianbenying
  • 甚至会打语义描述:「那个什么花少女的歌」

前几种用字符串技巧还能救。最后一种是字符串搜索天生无解的 —— 你没法穷举所有「玩家会怎么形容一首歌」。这才是我们引入 embedding 的真正动机:不是「向量检索很酷」,而是有一类查询字符串永远接不住。

二、整体架构:三层漏斗,向量只做兜底#

命中 ~0.2ms

命中 ~30ms

命中 ~200ms+

空 / 失败

用户输入 query

① 字符串精确层

精确 / 前缀 / 子串 / 罗马音 / 拼音

返回结果

② 字符串模糊层

trigram + 编辑距离

③ 向量兜底层

embedding API + 余弦相似度

返回「没找到」

命中 ~0.2ms

命中 ~30ms

命中 ~200ms+

空 / 失败

用户输入 query

① 字符串精确层

精确 / 前缀 / 子串 / 罗马音 / 拼音

返回结果

② 字符串模糊层

trigram + 编辑距离

③ 向量兜底层

embedding API + 余弦相似度

返回「没找到」

关键决策是顺序,不是技术选型。

①② 覆盖了约 96% 的真实查询(拿生产环境 1394 首曲库验证过),③ 只服务「意译 / 语义描述」这类字符串救不回来的剩下 4%。

反过来会怎样

如果把向量层放前面,每次查询都要吃一次网络往返(embedding API 是远端服务),等于把 200ms 的成本摊到 100% 的查询上,只为解决 4% 的问题。

贵的层放最后,是检索系统设计的通用原则。

三、字符串层:先穷尽「便宜」的手段#

在碰向量之前,把便宜的招数打满。核心是一条归一化管线:

原串

NFKC

全角→半角

小写

丢装饰符号

简体→日文异体

压空白

归一化形

原串

NFKC

全角→半角

小写

丢装饰符号

简体→日文异体

压空白

归一化形

几个值得说的点:

1. 简体 → 日文异体字映射表。 樱→桜泽→沢铁→鉄……只收「形近但字不同」的映射。同形字(花→花)是噪声,不收。这张表只能手工维护,因为中日汉字对应没有简单算法,也不存在权威的自动化映射。

2. 假名 → 罗马音静态表。 舞萌曲库里出现过的假名只有 152 种,全在标准五十音范围内。一个 dict 就够了,不需要引入任何分词库或罗马音转换依赖。用户打 senbonzakura,我们把曲名里的假名转成罗马音去比。

3. 装饰符号全丢。 ★☆♪、全角括号、长音符 ……这些在曲名里大量出现,但玩家永远不会输入。让它们参与匹配只会添乱。

归一化之后,① 层做精确 / 前缀 / 子串匹配,② 层用 trigram + 编辑距离兜住错别字。

为什么不上分词 / 全文索引

曲名是短文本,且高度非自然语言(假名、符号、英文混排)。jieba 这类分词器对曲名的切分结果不稳定,反而会引入噪声;曲库规模也不值得上 SQLite FTS 之类的索引。归一化 + trigram 在这个量级已经够用。

四、向量层:只对「漏网之鱼」出手#

4.1 离线算一次,在线算一次#

成本控制的核心就这一句话:

  • 曲库向量离线算一次:跑 scripts/build_music_vectors.py,把全部曲名批量交给 embedding API 算好,存进本地数据库。换模型或改文本时用 --rebuild-stale 增量重算。
  • 在线只算查询那一句:用户发「那个什么花少女」,只对这一句话调 1 次 embedding API,然后和本地的 1400 条向量比余弦相似度。

一次 embedding 调用(一句话、1024 维)按量计费基本可以忽略;如果你在本地跑模型(比如 bge-m3),成本是零,代价只是内存和启动时间。

在线查询本地库Embedding API离线脚本在线查询本地库Embedding API离线脚本1394 条曲名(批量)1394 × 1024 维向量存 BLOB + model_name1 条查询文本1 × 1024 维向量读全量向量矩阵(热缓存)矩阵乘法 → 余弦相似度 Top-K
在线查询本地库Embedding API离线脚本在线查询本地库Embedding API离线脚本1394 条曲名(批量)1394 × 1024 维向量存 BLOB + model_name1 条查询文本1 × 1024 维向量读全量向量矩阵(热缓存)矩阵乘法 → 余弦相似度 Top-K

4.2 喂给模型的文本是「人可改」的#

存库里有个 text_blob 字段,是真正喂给 embedding 模型的文本。它不是简单塞曲名,而是把归一化形、罗马音、拼音全拼进去 —— 让向量天然带上跨书写系统的语义

这样 happy synthesizerハッピーシンセサイザ 即使字符串层没命中,向量距离也会很近。

这也是给某首歌补别名的地方:发现某首歌搜不到,就往 text_blob 里加别名,重算那一条向量。

text_blob vs embedding 字段

text_blob 是人写的,embedding / model_name 是机器写的。

手动去编辑 embedding 里的浮点数没有任何意义 —— 向量是模型学出来的语义坐标,不是可以手调的参数。要改语义,改文本,然后重算。

4.3 为什么不用 faiss / chromadb / milvus#

这是整个设计里最反直觉的部分:我们做了向量检索,但没有用任何向量数据库

先算一笔账:

数值
曲库规模1394 首
向量维度1024
数据类型float32
总占用≈ 5.7 MB
暴力算余弦(Top-K)~1ms 量级

5.7MB 全部装进一个 numpy 矩阵,暴力算余弦相似度只要 1ms 量级 —— 比任何 ANN(近似最近邻)索引都快,而且是精确结果,零精度损失。

那引入 faiss 的成本呢?C++ 编译依赖(生产环境是 Python 3.14,wheel 覆盖不全)、安装体积、维护成本,而收益为零。就算曲库涨到 10 万首也才 400MB,暴力算仍是几毫秒。

结论

真到百万级再换不迟。

「用向量检索」和「用向量数据库」是两回事。小数据集上,numpy 就是最好的向量数据库。

4.4 工程细节#

  • embedding 存 BLOB 不存 JSON:float32 二进制体积约是 JSON 文本的 1/3,np.frombuffer 零拷贝解析,启动加载 1400 条只要几毫秒。
  • 热缓存:启动时把全量向量规整成一个 numpy 矩阵常驻内存,查询一次矩阵乘法出 Top-K。没装 numpy 时降级为纯 Python 循环 —— 慢一点,但能用。
  • 阈值过滤:余弦相似度低于 0.55(可配置)视为「没找到」,避免强行返回不相关的结果误导用户。
  • boost / disabled:每首歌可以设排序加权或直接屏蔽,给运营留手动干预的口子。
  • 热生效配置:模型、Base URL、API Key、开关、阈值都存数据库,管理员在群里发 向量检索配置 模型 bge-m3 就能改,不用重启。换模型名后需要跑一次重算(不同模型的向量空间不通用)。
  • 失败静默降级:embedding API 挂了、没配置、向量库为空 —— 向量层一律返回空列表并记日志,字符串层照常工作。
增强功能绝不能拖垮主流程

向量检索是锦上添花。 它的任何失败都不应该影响查歌这个主功能。

生产环境里,一个「可选增强」把自己失败成主流程故障,是最常见的翻车方式。

五、和 NoneBot 异步模型的配合#

最后一层工程约束:检索的同步路径里包含 DB 读和 numpy 计算,不能直接跑在事件循环里,否则会卡住整个 bot。

# search_sync 是同步实现,调用方必须用线程池包裹
result = await asyncio.to_thread(search_sync, query)
# search_async 是现成的异步入口,内部就是 to_thread(search_sync)
result = await search_async(query)
# embedding 客户端提供同步 embed_batch(批量、带二分重试),异步包装同样走线程池

这延续了项目一贯的规矩:NoneBot 事件循环里禁止 PyMySQL、慢 SQLite、Pillow、numpy 大计算 —— 该进线程的进线程。

六、效果与总结#

耗时覆盖
① 字符串精确 / 前缀 / 子串 / 罗马音 / 拼音~0.2ms绝大多数查询
② trigram + 编辑距离模糊~30ms错别字、书写变体
③ 向量兜底(仅 ①② 都空时)~200ms+意译、语义描述

总结:

  1. 先穷尽便宜的招,再上贵的。 归一化 + 罗马音 / 拼音映射能吃掉 96% 的查询,向量只做那 4% 的兜底。
  2. 小数据集不需要向量数据库。 几 MB 的矩阵,numpy 暴力算是最快、最准、依赖最少的方案。
  3. 离线算曲库、在线算查询。 把 embedding API 成本压到几乎为零。
  4. 喂给模型的文本是运营接口。 text_blob 设计成「人可改」,别名补在那里,向量自然懂。
  5. 增强功能必须可降级。 向量层任何失败都静默返回空,主流程永不受影响。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
给 1400 首歌做语义搜索:一个 QQ 机器人里的曲名检索设计
https://blog.wmc.pub/posts/music-semantic-search/
作者
Milk
发布于
2026-09-16
许可协议
CC BY-NC-SA 4.0
随机文章随机推荐
Profile Image of the Author
Milk
AWMC Founder
分类
标签
站点统计
文章
2
分类
2
标签
5
总字数
2,224
运行时长
0
最后活动
0 天前
文章目录